@ilaomianyang/dsh-memory-graph 0.10.0 → 0.10.1
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 +6 -1
- package/README.en.md +32 -1
- package/README.md +32 -1
- package/package.json +4 -2
- package/src/graph.js +1582 -1582
package/src/graph.js
CHANGED
|
@@ -1,1582 +1,1582 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Graph view: the whole vault as one self-contained HTML page.
|
|
3
|
-
*
|
|
4
|
-
* The output is deliberately a single file with inline CSS and inline script —
|
|
5
|
-
* no network, no CDN, no font download, no build step. Double-click it and it
|
|
6
|
-
* works; zip the vault and it still works. Nothing is fetched, so the page says
|
|
7
|
-
* the same thing offline ten years from now.
|
|
8
|
-
*
|
|
9
|
-
* Layout is computed here, in Node, from the vault content: the page opens
|
|
10
|
-
* already settled instead of animating from a random pile, and two runs over
|
|
11
|
-
* the same vault produce the same picture (byte for byte, generation stamp
|
|
12
|
-
* aside). The browser side only draws, filters and hit-tests.
|
|
13
|
-
*/
|
|
14
|
-
import crypto from 'node:crypto'
|
|
15
|
-
|
|
16
|
-
/** Five card kinds, one hue each. Entity nodes get their own near-white. */
|
|
17
|
-
export const KIND_COLORS = {
|
|
18
|
-
decision: '#6ee7b7',
|
|
19
|
-
fact: '#7dd3fc',
|
|
20
|
-
open_question: '#fbbf24',
|
|
21
|
-
preference: '#c4b5fd',
|
|
22
|
-
note: '#94a3b8',
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const KIND_LABELS = {
|
|
26
|
-
decision: '决策 decision',
|
|
27
|
-
fact: '事实 fact',
|
|
28
|
-
open_question: '待解 open_question',
|
|
29
|
-
preference: '偏好 preference',
|
|
30
|
-
note: '记录 note',
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// The legend sits in a status bar below the canvas, so the only thing the
|
|
34
|
-
// layout has to keep clear at the bottom is the frame itself.
|
|
35
|
-
const VIEW = { width: 1400, height: 860, pad: 70, reserveBottom: 16 }
|
|
36
|
-
|
|
37
|
-
const LABEL_FONT = { card: 10.5, entity: 11.5 }
|
|
38
|
-
const LABEL_MAX = { card: 126, entity: 138 }
|
|
39
|
-
|
|
40
|
-
/* ---------------------------------------------------------------- helpers */
|
|
41
|
-
|
|
42
|
-
function round1(n) {
|
|
43
|
-
return Math.round(n * 10) / 10
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function clampText(text, max) {
|
|
47
|
-
const flat = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
48
|
-
if (flat.length <= max) return flat
|
|
49
|
-
return `${flat.slice(0, max - 1).trimEnd()}…`
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Rough advance width, no font metrics in Node: CJK is full-width, latin about
|
|
54
|
-
* 0.6em (system-ui at 10.5px averages a bit wider than the classic 0.5em).
|
|
55
|
-
* Baked label boxes only need to be close, not exact — they are used to avoid
|
|
56
|
-
* collisions, and the browser strokes every label with the page colour so a
|
|
57
|
-
* couple of pixels of drift stay readable.
|
|
58
|
-
*/
|
|
59
|
-
function textWidth(text, size) {
|
|
60
|
-
let width = 0
|
|
61
|
-
for (const char of String(text)) {
|
|
62
|
-
const code = char.codePointAt(0)
|
|
63
|
-
if (code >= 0x2e80 && code <= 0x9fff) width += size
|
|
64
|
-
else if (code >= 0xff00 && code <= 0xffef) width += size
|
|
65
|
-
else if (code >= 0x3000 && code <= 0x303f) width += size
|
|
66
|
-
else if (char === ' ') width += size * 0.32
|
|
67
|
-
else width += size * 0.6
|
|
68
|
-
}
|
|
69
|
-
return width
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function truncateToWidth(text, size, maxWidth) {
|
|
73
|
-
if (textWidth(text, size) <= maxWidth) return text
|
|
74
|
-
let out = ''
|
|
75
|
-
for (const char of String(text)) {
|
|
76
|
-
if (textWidth(`${out}${char}…`, size) > maxWidth) break
|
|
77
|
-
out += char
|
|
78
|
-
}
|
|
79
|
-
return `${out.trimEnd()}…`
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function hashString(text) {
|
|
83
|
-
let h = 2166136261
|
|
84
|
-
for (let i = 0; i < text.length; i++) {
|
|
85
|
-
h ^= text.charCodeAt(i)
|
|
86
|
-
h = Math.imul(h, 16777619)
|
|
87
|
-
}
|
|
88
|
-
return h >>> 0
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function makeRng(seed) {
|
|
92
|
-
let state = (seed >>> 0) || 1
|
|
93
|
-
return () => {
|
|
94
|
-
state = (state + 0x6d2b79f5) >>> 0
|
|
95
|
-
let t = state
|
|
96
|
-
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
97
|
-
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
98
|
-
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function cardRadius(degree) {
|
|
103
|
-
return round1(5.4 + Math.min(4.2, Math.sqrt(degree) * 1.7))
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function entityRadius(degree) {
|
|
107
|
-
return round1(8.5 + Math.min(10.5, Math.sqrt(degree) * 2.2))
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/* ---------------------------------------------------------------- model */
|
|
111
|
-
|
|
112
|
-
function buildModel({ cards, entities, projects }) {
|
|
113
|
-
const nodes = []
|
|
114
|
-
const bySlug = new Map()
|
|
115
|
-
const byId = new Map()
|
|
116
|
-
|
|
117
|
-
for (const entity of entities) {
|
|
118
|
-
const node = {
|
|
119
|
-
key: `e:${entity.slug}`,
|
|
120
|
-
type: 'entity',
|
|
121
|
-
kind: 'entity',
|
|
122
|
-
label: String(entity.name ?? entity.slug),
|
|
123
|
-
slug: entity.slug,
|
|
124
|
-
degree: 0,
|
|
125
|
-
r: 0,
|
|
126
|
-
projects: [],
|
|
127
|
-
mentions: (entity.mentions ?? []).map((mention) => ({
|
|
128
|
-
d: String(mention.date ?? '').slice(0, 10),
|
|
129
|
-
note: clampText(mention.note, 120),
|
|
130
|
-
cardId: mention.cardId,
|
|
131
|
-
})),
|
|
132
|
-
etype: entity.type ?? 'concept',
|
|
133
|
-
aliases: entity.aliases ?? [],
|
|
134
|
-
firstSeen: entity.firstSeen ?? null,
|
|
135
|
-
lastSeen: entity.lastSeen ?? null,
|
|
136
|
-
}
|
|
137
|
-
bySlug.set(entity.slug, node)
|
|
138
|
-
nodes.push(node)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
for (const card of cards) {
|
|
142
|
-
const node = {
|
|
143
|
-
key: `c:${card.id}`,
|
|
144
|
-
type: 'card',
|
|
145
|
-
kind: String(card.kind ?? 'note'),
|
|
146
|
-
label: String(card.title ?? '(untitled)'),
|
|
147
|
-
id: card.id,
|
|
148
|
-
project: card.project ?? 'shared',
|
|
149
|
-
status: card.status ?? 'active',
|
|
150
|
-
confidence: card.confidence ?? 'inferred',
|
|
151
|
-
created: String(card.created ?? '').slice(0, 10),
|
|
152
|
-
snippet: clampText(card.body, 160),
|
|
153
|
-
body: clampText(card.body, 900),
|
|
154
|
-
entities: (card.entities ?? []).map((slug) => ({
|
|
155
|
-
slug,
|
|
156
|
-
name: bySlug.get(slug)?.label ?? slug,
|
|
157
|
-
})),
|
|
158
|
-
supersedes: card.supersedes ?? [],
|
|
159
|
-
supersededBy: card.supersededBy ?? null,
|
|
160
|
-
tags: card.tags ?? [],
|
|
161
|
-
degree: 0,
|
|
162
|
-
r: 0,
|
|
163
|
-
projects: [card.project ?? 'shared'],
|
|
164
|
-
}
|
|
165
|
-
byId.set(card.id, node)
|
|
166
|
-
nodes.push(node)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const links = []
|
|
170
|
-
const seen = new Set()
|
|
171
|
-
for (const card of cards) {
|
|
172
|
-
const source = byId.get(card.id)
|
|
173
|
-
for (const slug of card.entities ?? []) {
|
|
174
|
-
const target = bySlug.get(slug)
|
|
175
|
-
if (!target) continue
|
|
176
|
-
const key = `${source.key}->${target.key}`
|
|
177
|
-
if (seen.has(key)) continue
|
|
178
|
-
seen.add(key)
|
|
179
|
-
links.push({ s: source.key, t: target.key, k: 'mention' })
|
|
180
|
-
source.degree += 1
|
|
181
|
-
target.degree += 1
|
|
182
|
-
if (!target.projects.includes(source.project)) target.projects.push(source.project)
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
for (const card of cards) {
|
|
187
|
-
const source = byId.get(card.id)
|
|
188
|
-
const old = card.supersededBy ? byId.get(card.supersededBy) : null
|
|
189
|
-
if (!source || !old) continue
|
|
190
|
-
const key = `${source.key}->${old.key}`
|
|
191
|
-
if (seen.has(key)) continue
|
|
192
|
-
seen.add(key)
|
|
193
|
-
links.push({ s: source.key, t: old.key, k: 'supersede' })
|
|
194
|
-
source.degree += 1
|
|
195
|
-
old.degree += 1
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
for (const node of nodes) {
|
|
199
|
-
node.r = node.type === 'entity' ? entityRadius(node.degree) : cardRadius(node.degree)
|
|
200
|
-
node.haloScale = node.type === 'entity' ? 2.5 : 2.7
|
|
201
|
-
// A hub is an entity that only exists because several projects share it.
|
|
202
|
-
node.hub = node.type === 'entity' && node.projects.length >= 2
|
|
203
|
-
node.projects.sort()
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const projectCounts = new Map()
|
|
207
|
-
for (const card of cards) {
|
|
208
|
-
const slug = card.project ?? 'shared'
|
|
209
|
-
projectCounts.set(slug, (projectCounts.get(slug) ?? 0) + 1)
|
|
210
|
-
}
|
|
211
|
-
const projectList = [...projectCounts.entries()]
|
|
212
|
-
.map(([slug, count]) => ({ slug, count, title: projects.find((p) => p.slug === slug)?.title ?? slug }))
|
|
213
|
-
.sort((a, b) => b.count - a.count || a.slug.localeCompare(b.slug))
|
|
214
|
-
|
|
215
|
-
return { nodes, links, projectList }
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/* ---------------------------------------------------------------- layout */
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Fruchterman–Reingold relaxation with a gravity term: springs along links,
|
|
222
|
-
* inverse-distance repulsion between every pair, a stronger pull to the middle
|
|
223
|
-
* for entity nodes so the things that many cards share end up in the centre.
|
|
224
|
-
* Deterministic (phyllotaxis start, no wall clock, no Math.random).
|
|
225
|
-
*/
|
|
226
|
-
function layoutGraph(nodes, links) {
|
|
227
|
-
const { width, height } = VIEW
|
|
228
|
-
const n = nodes.length
|
|
229
|
-
const index = new Map(nodes.map((node, i) => [node.key, i]))
|
|
230
|
-
const xs = new Float64Array(n)
|
|
231
|
-
const ys = new Float64Array(n)
|
|
232
|
-
const dxs = new Float64Array(n)
|
|
233
|
-
const dys = new Float64Array(n)
|
|
234
|
-
const rng = makeRng(hashString(nodes.map((node) => node.key).join('|')))
|
|
235
|
-
|
|
236
|
-
// Golden-angle spiral: evenly spaced, no clusters to untangle first.
|
|
237
|
-
const spread = Math.min(width, height) * 0.34
|
|
238
|
-
for (let i = 0; i < n; i++) {
|
|
239
|
-
const angle = i * 2.399963229728653
|
|
240
|
-
const radius = spread * Math.sqrt((i + 0.5) / Math.max(1, n))
|
|
241
|
-
xs[i] = width / 2 + Math.cos(angle) * radius
|
|
242
|
-
ys[i] = height / 2 + Math.sin(angle) * radius * 0.82
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const edges = []
|
|
246
|
-
for (const link of links) {
|
|
247
|
-
const a = index.get(link.s)
|
|
248
|
-
const b = index.get(link.t)
|
|
249
|
-
if (a === undefined || b === undefined) continue
|
|
250
|
-
edges.push([a, b, link.k === 'supersede' ? 1.35 : 1])
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Fruchterman–Reingold wants an ideal edge length that grows as the frame gets
|
|
254
|
-
// emptier, which is right for big graphs and absurd for small ones: two cells
|
|
255
|
-
// would be flung to opposite corners. Hence the ceiling.
|
|
256
|
-
const ideal = Math.min(132, 1.05 * Math.sqrt((width * height) / Math.max(8, n)))
|
|
257
|
-
let temperature = ideal * 0.9
|
|
258
|
-
const iterations = 460
|
|
259
|
-
|
|
260
|
-
for (let iter = 0; iter < iterations; iter++) {
|
|
261
|
-
dxs.fill(0)
|
|
262
|
-
dys.fill(0)
|
|
263
|
-
|
|
264
|
-
for (let i = 0; i < n; i++) {
|
|
265
|
-
for (let j = i + 1; j < n; j++) {
|
|
266
|
-
let dx = xs[i] - xs[j]
|
|
267
|
-
let dy = ys[i] - ys[j]
|
|
268
|
-
let d2 = dx * dx + dy * dy
|
|
269
|
-
if (d2 < 0.02) {
|
|
270
|
-
dx = (rng() - 0.5) * 0.6
|
|
271
|
-
dy = (rng() - 0.5) * 0.6
|
|
272
|
-
d2 = dx * dx + dy * dy + 0.02
|
|
273
|
-
}
|
|
274
|
-
const d = Math.sqrt(d2)
|
|
275
|
-
const force = (ideal * ideal) / d
|
|
276
|
-
const ux = dx / d
|
|
277
|
-
const uy = dy / d
|
|
278
|
-
dxs[i] += ux * force
|
|
279
|
-
dys[i] += uy * force
|
|
280
|
-
dxs[j] -= ux * force
|
|
281
|
-
dys[j] -= uy * force
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
for (const [a, b, weight] of edges) {
|
|
286
|
-
const dx = xs[a] - xs[b]
|
|
287
|
-
const dy = ys[a] - ys[b]
|
|
288
|
-
const d = Math.sqrt(dx * dx + dy * dy) || 0.01
|
|
289
|
-
const force = ((d * d) / ideal) * 0.82 * weight
|
|
290
|
-
const ux = dx / d
|
|
291
|
-
const uy = dy / d
|
|
292
|
-
dxs[a] -= ux * force
|
|
293
|
-
dys[a] -= uy * force
|
|
294
|
-
dxs[b] += ux * force
|
|
295
|
-
dys[b] += uy * force
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
for (let i = 0; i < n; i++) {
|
|
299
|
-
// Entities are the hubs: hold them near the middle so cards ring them.
|
|
300
|
-
const gravity = nodes[i].type === 'entity' ? 0.19 : 0.062
|
|
301
|
-
dxs[i] += (width / 2 - xs[i]) * gravity
|
|
302
|
-
dys[i] += (height / 2 - ys[i]) * gravity
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
for (let i = 0; i < n; i++) {
|
|
306
|
-
const d = Math.sqrt(dxs[i] * dxs[i] + dys[i] * dys[i]) || 1
|
|
307
|
-
const step = Math.min(d, temperature)
|
|
308
|
-
xs[i] += (dxs[i] / d) * step
|
|
309
|
-
ys[i] += (dys[i] / d) * step
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Hard separation: two nodes never share a spot, whatever the forces say.
|
|
313
|
-
for (let i = 0; i < n; i++) {
|
|
314
|
-
for (let j = i + 1; j < n; j++) {
|
|
315
|
-
const min = nodes[i].r + nodes[j].r + 15
|
|
316
|
-
let dx = xs[j] - xs[i]
|
|
317
|
-
let dy = ys[j] - ys[i]
|
|
318
|
-
let d = Math.sqrt(dx * dx + dy * dy)
|
|
319
|
-
if (d >= min) continue
|
|
320
|
-
if (d < 0.01) {
|
|
321
|
-
dx = rng() - 0.5
|
|
322
|
-
dy = rng() - 0.5
|
|
323
|
-
d = Math.sqrt(dx * dx + dy * dy) || 0.01
|
|
324
|
-
}
|
|
325
|
-
const push = (min - d) / 2
|
|
326
|
-
xs[i] -= (dx / d) * push
|
|
327
|
-
ys[i] -= (dy / d) * push
|
|
328
|
-
xs[j] += (dx / d) * push
|
|
329
|
-
ys[j] += (dy / d) * push
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
temperature = Math.max(0.6, temperature * 0.986)
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Fit into the padded frame. The two axes are allowed to stretch by different
|
|
337
|
-
// amounts (capped): a round cloud would leave the wide frame half empty, and
|
|
338
|
-
// stretching positions — never the circles — keeps every node perfectly round.
|
|
339
|
-
let minX = Infinity
|
|
340
|
-
let minY = Infinity
|
|
341
|
-
let maxX = -Infinity
|
|
342
|
-
let maxY = -Infinity
|
|
343
|
-
for (let i = 0; i < n; i++) {
|
|
344
|
-
minX = Math.min(minX, xs[i] - nodes[i].r)
|
|
345
|
-
maxX = Math.max(maxX, xs[i] + nodes[i].r)
|
|
346
|
-
minY = Math.min(minY, ys[i] - nodes[i].r)
|
|
347
|
-
maxY = Math.max(maxY, ys[i] + nodes[i].r)
|
|
348
|
-
}
|
|
349
|
-
const boxW = VIEW.width - VIEW.pad * 2
|
|
350
|
-
// The legend and the hint live in a strip along the bottom edge; nodes are
|
|
351
|
-
// kept out of it so a cluster can never end up behind the legend box.
|
|
352
|
-
const boxH = VIEW.height - VIEW.pad - VIEW.reserveBottom
|
|
353
|
-
const spanW = Math.max(1, maxX - minX)
|
|
354
|
-
const spanH = Math.max(1, maxY - minY)
|
|
355
|
-
const scaleX = Math.min(boxW / spanW, 1.45)
|
|
356
|
-
const scaleY = Math.min(boxH / spanH, 1.35)
|
|
357
|
-
const radiusScale = Math.min(1.3, Math.max(0.9, Math.sqrt(scaleX * scaleY)))
|
|
358
|
-
const offsetX = VIEW.pad + (boxW - spanW * scaleX) / 2
|
|
359
|
-
const offsetY = VIEW.pad + (boxH - spanH * scaleY) / 2
|
|
360
|
-
|
|
361
|
-
for (let i = 0; i < n; i++) {
|
|
362
|
-
nodes[i].x = round1(offsetX + (xs[i] - minX) * scaleX)
|
|
363
|
-
nodes[i].y = round1(offsetY + (ys[i] - minY) * scaleY)
|
|
364
|
-
nodes[i].r = round1(nodes[i].r * radiusScale)
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// The radii that were just applied are not the ones the span above was
|
|
368
|
-
// measured with, so the outermost cells can poke a few pixels past the
|
|
369
|
-
// padding. Re-measure with the real circles and pull the picture back in —
|
|
370
|
-
// positions shrink, circles never do.
|
|
371
|
-
const frame = {
|
|
372
|
-
x0: VIEW.pad,
|
|
373
|
-
x1: VIEW.width - VIEW.pad,
|
|
374
|
-
y0: VIEW.pad,
|
|
375
|
-
y1: VIEW.height - VIEW.reserveBottom,
|
|
376
|
-
}
|
|
377
|
-
let bx0 = Infinity
|
|
378
|
-
let bx1 = -Infinity
|
|
379
|
-
let by0 = Infinity
|
|
380
|
-
let by1 = -Infinity
|
|
381
|
-
for (let i = 0; i < n; i++) {
|
|
382
|
-
bx0 = Math.min(bx0, nodes[i].x - nodes[i].r)
|
|
383
|
-
bx1 = Math.max(bx1, nodes[i].x + nodes[i].r)
|
|
384
|
-
by0 = Math.min(by0, nodes[i].y - nodes[i].r)
|
|
385
|
-
by1 = Math.max(by1, nodes[i].y + nodes[i].r)
|
|
386
|
-
}
|
|
387
|
-
if (bx0 < frame.x0 || bx1 > frame.x1 || by0 < frame.y0 || by1 > frame.y1) {
|
|
388
|
-
const shrink = Math.min(
|
|
389
|
-
(frame.x1 - frame.x0) / Math.max(1, bx1 - bx0),
|
|
390
|
-
(frame.y1 - frame.y0) / Math.max(1, by1 - by0),
|
|
391
|
-
1,
|
|
392
|
-
)
|
|
393
|
-
const cx = (bx0 + bx1) / 2
|
|
394
|
-
const cy = (by0 + by1) / 2
|
|
395
|
-
const tx = (frame.x0 + frame.x1) / 2
|
|
396
|
-
const ty = (frame.y0 + frame.y1) / 2
|
|
397
|
-
for (let i = 0; i < n; i++) {
|
|
398
|
-
nodes[i].x = round1(tx + (nodes[i].x - cx) * shrink)
|
|
399
|
-
nodes[i].y = round1(ty + (nodes[i].y - cy) * shrink)
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/* ---------------------------------------------------------------- labels */
|
|
405
|
-
|
|
406
|
-
function overlaps(a, b) {
|
|
407
|
-
return !(a.x1 <= b.x0 || b.x1 <= a.x0 || a.y1 <= b.y0 || b.y1 <= a.y0)
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Greedy label placement: ten preferred offsets around the node, biggest nodes
|
|
412
|
-
* first. A label that fits nowhere is kept but hidden by default (hover,
|
|
413
|
-
* selection and search reveal it), which is what keeps the picture from turning
|
|
414
|
-
* into a plate of confetti once a vault has fifty entities.
|
|
415
|
-
*/
|
|
416
|
-
function placeLabels(nodes) {
|
|
417
|
-
const bodies = nodes.map((node) => ({
|
|
418
|
-
node,
|
|
419
|
-
box: {
|
|
420
|
-
x0: node.x - node.r - 2,
|
|
421
|
-
x1: node.x + node.r + 2,
|
|
422
|
-
y0: node.y - node.r - 2,
|
|
423
|
-
y1: node.y + node.r + 2,
|
|
424
|
-
},
|
|
425
|
-
}))
|
|
426
|
-
const taken = []
|
|
427
|
-
|
|
428
|
-
const ordered = nodes
|
|
429
|
-
.slice()
|
|
430
|
-
.sort((a, b) => {
|
|
431
|
-
if (a.type !== b.type) return a.type === 'entity' ? -1 : 1
|
|
432
|
-
return b.degree - a.degree
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
const floor = VIEW.height - VIEW.reserveBottom - 6
|
|
436
|
-
|
|
437
|
-
for (const node of ordered) {
|
|
438
|
-
const size = node.type === 'entity' ? LABEL_FONT.entity : LABEL_FONT.card
|
|
439
|
-
const text = truncateToWidth(node.label, size, node.type === 'entity' ? LABEL_MAX.entity : LABEL_MAX.card)
|
|
440
|
-
node.short = text
|
|
441
|
-
const width = textWidth(text, size) + 10
|
|
442
|
-
const candidates = [
|
|
443
|
-
{ dx: 0, dy: node.r + 10, anchor: 'middle' },
|
|
444
|
-
{ dx: 0, dy: -(node.r + 8), anchor: 'middle' },
|
|
445
|
-
{ dx: node.r + 7, dy: 1, anchor: 'start' },
|
|
446
|
-
{ dx: -(node.r + 7), dy: 1, anchor: 'end' },
|
|
447
|
-
{ dx: 0, dy: node.r + 24, anchor: 'middle' },
|
|
448
|
-
{ dx: 0, dy: -(node.r + 22), anchor: 'middle' },
|
|
449
|
-
{ dx: node.r + 7, dy: -(node.r * 0.6 + 5), anchor: 'start' },
|
|
450
|
-
{ dx: node.r + 7, dy: node.r * 0.6 + 6, anchor: 'start' },
|
|
451
|
-
{ dx: -(node.r + 7), dy: -(node.r * 0.6 + 5), anchor: 'end' },
|
|
452
|
-
{ dx: -(node.r + 7), dy: node.r * 0.6 + 6, anchor: 'end' },
|
|
453
|
-
]
|
|
454
|
-
|
|
455
|
-
let placed = null
|
|
456
|
-
for (const candidate of candidates) {
|
|
457
|
-
const x = node.x + candidate.dx
|
|
458
|
-
const y = node.y + candidate.dy
|
|
459
|
-
const left = candidate.anchor === 'start' ? x : candidate.anchor === 'end' ? x - width : x - width / 2
|
|
460
|
-
// y is the text baseline, so the box is lopsided: cap height above the
|
|
461
|
-
// baseline, descender below it. Inflated a little on top of that, because
|
|
462
|
-
// the width estimate can be off by a pixel or two per glyph run.
|
|
463
|
-
const box = {
|
|
464
|
-
x0: left - 1.5,
|
|
465
|
-
x1: left + width + 1.5,
|
|
466
|
-
y0: y - size * 0.84 - 1.5,
|
|
467
|
-
y1: y + size * 0.24 + 1.5,
|
|
468
|
-
}
|
|
469
|
-
// Never let a label run off the frame: the page clips, and half a word is
|
|
470
|
-
// worse than no word.
|
|
471
|
-
if (box.x0 < 10 || box.x1 > VIEW.width - 10 || box.y0 < 8 || box.y1 > floor) continue
|
|
472
|
-
if (taken.some((other) => overlaps(box, other))) continue
|
|
473
|
-
// Other nodes are hard obstacles; the node this label belongs to is not,
|
|
474
|
-
// otherwise every candidate would collide with its own cell body.
|
|
475
|
-
if (bodies.some((entry) => entry.node !== node && overlaps(box, entry.box))) continue
|
|
476
|
-
taken.push(box)
|
|
477
|
-
placed = { x: round1(x), y: round1(y), anchor: candidate.anchor, on: true }
|
|
478
|
-
break
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
node.labelPos = placed ?? { x: round1(node.x), y: round1(node.y + node.r + 11), anchor: 'middle', on: false }
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/* ---------------------------------------------------------------- payload */
|
|
486
|
-
|
|
487
|
-
function toPayload({ cards, entities, projects, generatedAt }) {
|
|
488
|
-
const model = buildModel({ cards, entities, projects })
|
|
489
|
-
layoutGraph(model.nodes, model.links)
|
|
490
|
-
placeLabels(model.nodes)
|
|
491
|
-
|
|
492
|
-
const nodes = model.nodes.map((node) => {
|
|
493
|
-
const base = {
|
|
494
|
-
key: node.key,
|
|
495
|
-
type: node.type,
|
|
496
|
-
kind: node.kind,
|
|
497
|
-
label: node.short ?? node.label,
|
|
498
|
-
full: node.label,
|
|
499
|
-
x: node.x,
|
|
500
|
-
y: node.y,
|
|
501
|
-
r: node.r,
|
|
502
|
-
degree: node.degree,
|
|
503
|
-
projects: node.projects,
|
|
504
|
-
labelAt: node.labelPos,
|
|
505
|
-
}
|
|
506
|
-
if (node.type === 'entity') {
|
|
507
|
-
return {
|
|
508
|
-
...base,
|
|
509
|
-
slug: node.slug,
|
|
510
|
-
etype: node.etype,
|
|
511
|
-
aliases: node.aliases,
|
|
512
|
-
firstSeen: node.firstSeen,
|
|
513
|
-
lastSeen: node.lastSeen,
|
|
514
|
-
hub: node.hub,
|
|
515
|
-
mentions: node.mentions.slice(-24).reverse(),
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
return {
|
|
519
|
-
...base,
|
|
520
|
-
id: node.id,
|
|
521
|
-
project: node.project,
|
|
522
|
-
status: node.status,
|
|
523
|
-
confidence: node.confidence,
|
|
524
|
-
created: node.created,
|
|
525
|
-
snippet: node.snippet,
|
|
526
|
-
body: node.body,
|
|
527
|
-
entities: node.entities,
|
|
528
|
-
supersedes: node.supersedes,
|
|
529
|
-
supersededBy: node.supersededBy,
|
|
530
|
-
tags: node.tags,
|
|
531
|
-
}
|
|
532
|
-
})
|
|
533
|
-
|
|
534
|
-
const counts = {
|
|
535
|
-
cards: cards.length,
|
|
536
|
-
entities: entities.length,
|
|
537
|
-
projects: model.projectList.length,
|
|
538
|
-
active: cards.filter((card) => (card.status ?? 'active') === 'active').length,
|
|
539
|
-
superseded: cards.filter((card) => card.status === 'superseded').length,
|
|
540
|
-
retracted: cards.filter((card) => card.status === 'retracted').length,
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
return {
|
|
544
|
-
generatedAt,
|
|
545
|
-
counts,
|
|
546
|
-
kinds: KIND_LABELS,
|
|
547
|
-
nodes,
|
|
548
|
-
links: model.links,
|
|
549
|
-
projects: model.projectList,
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/* ---------------------------------------------------------------- assets */
|
|
554
|
-
|
|
555
|
-
/**
|
|
556
|
-
* Two palettes. `:root` is dark; a light system flips it before the script runs,
|
|
557
|
-
* and the header toggle pins either one with a `data-theme` attribute on <html>
|
|
558
|
-
* (auto means "no attribute", so the media query stays in charge).
|
|
559
|
-
*/
|
|
560
|
-
const TOKENS = {
|
|
561
|
-
dark: `
|
|
562
|
-
color-scheme: dark;
|
|
563
|
-
--bg-0: #070b0f;
|
|
564
|
-
--bg-1: #0d141b;
|
|
565
|
-
--bg-2: #16222e;
|
|
566
|
-
--fg: #e8eef5;
|
|
567
|
-
--fg-dim: #8fa2b6;
|
|
568
|
-
--line: rgba(148, 163, 184, 0.18);
|
|
569
|
-
--edge: rgba(148, 163, 184, 0.36);
|
|
570
|
-
--mint: #6ee7b7;
|
|
571
|
-
--entity: #e8eef5;
|
|
572
|
-
--panel: rgba(13, 20, 27, 0.9);
|
|
573
|
-
--chip: rgba(148, 163, 184, 0.12);
|
|
574
|
-
--glow-a: rgba(45, 212, 191, 0.11);
|
|
575
|
-
--glow-b: rgba(56, 189, 248, 0.08);
|
|
576
|
-
--shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
|
|
577
|
-
--k-decision: #6ee7b7;
|
|
578
|
-
--k-fact: #7dd3fc;
|
|
579
|
-
--k-open_question: #fbbf24;
|
|
580
|
-
--k-preference: #c4b5fd;
|
|
581
|
-
--k-note: #94a3b8;`,
|
|
582
|
-
light: `
|
|
583
|
-
color-scheme: light;
|
|
584
|
-
--bg-0: #eaeff5;
|
|
585
|
-
--bg-1: #f7fafc;
|
|
586
|
-
--bg-2: #ffffff;
|
|
587
|
-
--fg: #101820;
|
|
588
|
-
--fg-dim: #4d6076;
|
|
589
|
-
--line: rgba(16, 24, 32, 0.16);
|
|
590
|
-
--edge: rgba(16, 24, 32, 0.32);
|
|
591
|
-
--mint: #0b8f6b;
|
|
592
|
-
--entity: #16222e;
|
|
593
|
-
--panel: rgba(255, 255, 255, 0.94);
|
|
594
|
-
--chip: rgba(16, 24, 32, 0.055);
|
|
595
|
-
--glow-a: rgba(13, 148, 136, 0.10);
|
|
596
|
-
--glow-b: rgba(2, 132, 199, 0.07);
|
|
597
|
-
--shadow: 0 18px 44px rgba(16, 24, 32, 0.2);
|
|
598
|
-
--k-decision: #0b8f6b;
|
|
599
|
-
--k-fact: #0b74b8;
|
|
600
|
-
--k-open_question: #a45a06;
|
|
601
|
-
--k-preference: #6d28d9;
|
|
602
|
-
--k-note: #55677a;`,
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
const CSS = `
|
|
606
|
-
:root {${TOKENS.dark}
|
|
607
|
-
}
|
|
608
|
-
@media (prefers-color-scheme: light) {
|
|
609
|
-
:root {${TOKENS.light}
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
:root[data-theme="dark"] {${TOKENS.dark}
|
|
613
|
-
}
|
|
614
|
-
:root[data-theme="light"] {${TOKENS.light}
|
|
615
|
-
}
|
|
616
|
-
* { box-sizing: border-box; }
|
|
617
|
-
html, body { height: 100%; margin: 0; }
|
|
618
|
-
body {
|
|
619
|
-
background: var(--bg-0);
|
|
620
|
-
color: var(--fg);
|
|
621
|
-
font: 13px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, "Microsoft YaHei", "PingFang SC", sans-serif;
|
|
622
|
-
-webkit-font-smoothing: antialiased;
|
|
623
|
-
overflow: hidden;
|
|
624
|
-
}
|
|
625
|
-
#app { display: grid; grid-template-rows: auto 1fr auto; height: 100%; }
|
|
626
|
-
|
|
627
|
-
header {
|
|
628
|
-
display: flex; align-items: center; gap: 14px; flex-wrap: nowrap;
|
|
629
|
-
padding: 10px 16px 11px;
|
|
630
|
-
border-bottom: 1px solid var(--line);
|
|
631
|
-
background: linear-gradient(180deg, var(--bg-1), rgba(0, 0, 0, 0));
|
|
632
|
-
}
|
|
633
|
-
.brand { display: flex; align-items: baseline; gap: 9px; min-width: 0; flex: 0 0 auto; }
|
|
634
|
-
.brand h1 { font-size: 15px; font-weight: 650; margin: 0; letter-spacing: 0.01em; white-space: nowrap; }
|
|
635
|
-
.brand span.en { font-size: 11.5px; color: var(--fg-dim); letter-spacing: 0.06em; text-transform: uppercase; white-space: nowrap; }
|
|
636
|
-
.brand::before {
|
|
637
|
-
content: ""; width: 9px; height: 9px; border-radius: 50%;
|
|
638
|
-
background: var(--mint); box-shadow: 0 0 0 4px rgba(110, 231, 183, 0.16);
|
|
639
|
-
align-self: center; flex: none;
|
|
640
|
-
}
|
|
641
|
-
.counts {
|
|
642
|
-
font-size: 11.5px; color: var(--fg-dim); font-variant-numeric: tabular-nums;
|
|
643
|
-
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
644
|
-
}
|
|
645
|
-
.counts b { color: var(--fg); font-weight: 600; }
|
|
646
|
-
header .spacer { flex: 1 1 0; min-width: 8px; }
|
|
647
|
-
.controls { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
|
|
648
|
-
input[type="search"], select, button {
|
|
649
|
-
font: inherit; color: var(--fg); background: var(--chip);
|
|
650
|
-
border: 1px solid var(--line); border-radius: 8px; padding: 5px 9px; outline: none;
|
|
651
|
-
}
|
|
652
|
-
input[type="search"] { width: 210px; min-width: 96px; flex: 0 1 auto; }
|
|
653
|
-
input[type="search"]::placeholder { color: var(--fg-dim); }
|
|
654
|
-
input[type="search"]:focus, select:focus { border-color: var(--mint); box-shadow: 0 0 0 3px rgba(110, 231, 183, 0.16); }
|
|
655
|
-
select { padding-right: 6px; cursor: pointer; max-width: 190px; }
|
|
656
|
-
button { cursor: pointer; min-width: 30px; flex: 0 0 auto; }
|
|
657
|
-
button:hover { border-color: var(--mint); color: var(--mint); }
|
|
658
|
-
.zoom { display: flex; gap: 4px; flex: 0 0 auto; }
|
|
659
|
-
|
|
660
|
-
main { position: relative; display: grid; grid-template-columns: 1fr; min-height: 0; }
|
|
661
|
-
#stage {
|
|
662
|
-
position: relative; min-height: 0; overflow: hidden;
|
|
663
|
-
background:
|
|
664
|
-
radial-gradient(900px 560px at 50% 44%, var(--glow-a) 0%, rgba(0, 0, 0, 0) 62%),
|
|
665
|
-
radial-gradient(700px 520px at 16% 92%, var(--glow-b) 0%, rgba(0, 0, 0, 0) 58%),
|
|
666
|
-
var(--bg-0);
|
|
667
|
-
box-shadow: inset 0 0 200px 40px rgba(0, 0, 0, 0.35);
|
|
668
|
-
}
|
|
669
|
-
@media (prefers-color-scheme: light) {
|
|
670
|
-
#stage { box-shadow: inset 0 0 170px 30px rgba(148, 163, 184, 0.20); }
|
|
671
|
-
}
|
|
672
|
-
#canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; cursor: grab; }
|
|
673
|
-
#canvas.panning { cursor: grabbing; }
|
|
674
|
-
|
|
675
|
-
.edge-glow { fill: none; stroke: var(--edge); stroke-width: 3.6; stroke-opacity: 0.32; stroke-linecap: round; }
|
|
676
|
-
.edge { fill: none; stroke: var(--edge); stroke-width: 1.05; stroke-linecap: round; }
|
|
677
|
-
.edge-group.supersede .edge { stroke: var(--mint); stroke-opacity: 0.55; stroke-dasharray: 4 4; }
|
|
678
|
-
.edge-group.supersede .edge-glow { stroke: var(--mint); stroke-opacity: 0.16; }
|
|
679
|
-
.edge-group.lit .edge { stroke: var(--mint); stroke-width: 1.7; stroke-dasharray: 6 6; animation: flow 1.4s linear infinite; }
|
|
680
|
-
.edge-group.lit .edge-glow { stroke: var(--mint); stroke-opacity: 0.34; }
|
|
681
|
-
.edge-group.dim { opacity: 0.09; }
|
|
682
|
-
@keyframes flow { to { stroke-dashoffset: -24; } }
|
|
683
|
-
|
|
684
|
-
.node { cursor: pointer; }
|
|
685
|
-
.node .halo { opacity: 0.9; }
|
|
686
|
-
.node.hub .halo { opacity: 1; }
|
|
687
|
-
.node .core { fill: currentColor; stroke: var(--bg-0); stroke-width: 0.9; }
|
|
688
|
-
.node.entity .core { stroke: var(--bg-1); stroke-width: 1.2; }
|
|
689
|
-
.node .nucleus { fill: var(--bg-0); }
|
|
690
|
-
.node.hub .ring { fill: none; stroke: currentColor; stroke-opacity: 0.55; stroke-width: 1.1; stroke-dasharray: 3 3.4; animation: spin 9s linear infinite; }
|
|
691
|
-
@keyframes spin { to { stroke-dashoffset: -52; } }
|
|
692
|
-
.node.superseded .core { stroke: var(--fg); stroke-opacity: 0.6; stroke-width: 1.1; stroke-dasharray: 2.6 2.2; }
|
|
693
|
-
.node.retracted { opacity: 0.34; }
|
|
694
|
-
.node.retracted .core { stroke-dasharray: 1.6 1.8; }
|
|
695
|
-
.node.hit .core { stroke: var(--mint); stroke-width: 2; }
|
|
696
|
-
.node.hit .halo { opacity: 0.3; }
|
|
697
|
-
.node.sel .core { stroke: var(--mint); stroke-width: 2.4; }
|
|
698
|
-
.node.sel .halo { opacity: 0.38; }
|
|
699
|
-
.node:hover .core { stroke: var(--fg); stroke-width: 1.8; }
|
|
700
|
-
.node.dim { opacity: 0.12; }
|
|
701
|
-
.node.dim.retracted { opacity: 0.06; }
|
|
702
|
-
.node.dim .halo { opacity: 0.16; }
|
|
703
|
-
|
|
704
|
-
.k-decision { color: var(--k-decision); }
|
|
705
|
-
.k-fact { color: var(--k-fact); }
|
|
706
|
-
.k-open_question { color: var(--k-open_question); }
|
|
707
|
-
.k-preference { color: var(--k-preference); }
|
|
708
|
-
.k-note { color: var(--k-note); }
|
|
709
|
-
.k-entity { color: var(--entity); }
|
|
710
|
-
|
|
711
|
-
text.lbl {
|
|
712
|
-
font-family: inherit; font-size: 10.5px; font-weight: 500;
|
|
713
|
-
fill: var(--fg); fill-opacity: 0.82;
|
|
714
|
-
stroke: var(--bg-0); stroke-width: 3.2px; stroke-linejoin: round;
|
|
715
|
-
paint-order: stroke fill; pointer-events: none; user-select: none;
|
|
716
|
-
}
|
|
717
|
-
text.lbl.entity { font-size: 11.5px; font-weight: 650; fill-opacity: 0.96; }
|
|
718
|
-
text.lbl.off { display: none; }
|
|
719
|
-
text.lbl.dim { fill-opacity: 0.12; }
|
|
720
|
-
|
|
721
|
-
#empty {
|
|
722
|
-
position: absolute; inset: 0; display: flex; flex-direction: column;
|
|
723
|
-
align-items: center; justify-content: center; gap: 10px; padding: 40px;
|
|
724
|
-
text-align: center; color: var(--fg-dim);
|
|
725
|
-
}
|
|
726
|
-
#empty[hidden] { display: none; }
|
|
727
|
-
#empty h2 { margin: 0; font-size: 17px; color: var(--fg); font-weight: 600; }
|
|
728
|
-
#empty p { margin: 0; max-width: 540px; line-height: 1.85; }
|
|
729
|
-
#empty code { background: var(--chip); border-radius: 5px; padding: 1px 5px; font-size: 12px; }
|
|
730
|
-
|
|
731
|
-
#tooltip {
|
|
732
|
-
position: absolute; z-index: 6; max-width: 330px; padding: 10px 12px;
|
|
733
|
-
border-radius: 10px; border: 1px solid var(--line); background: var(--panel);
|
|
734
|
-
box-shadow: var(--shadow); backdrop-filter: blur(9px);
|
|
735
|
-
opacity: 0; transform: translateY(3px); transition: opacity 0.12s ease, transform 0.12s ease;
|
|
736
|
-
pointer-events: none;
|
|
737
|
-
}
|
|
738
|
-
#tooltip.on { opacity: 1; transform: none; }
|
|
739
|
-
#tooltip .tt-title { font-weight: 600; font-size: 12.5px; margin-bottom: 3px; }
|
|
740
|
-
#tooltip .tt-snip { color: var(--fg-dim); font-size: 11.5px; margin-bottom: 5px; }
|
|
741
|
-
#tooltip .tt-meta { color: var(--fg-dim); font-size: 10.5px; font-variant-numeric: tabular-nums; }
|
|
742
|
-
#tooltip .tt-kind { display: inline-block; margin-bottom: 5px; }
|
|
743
|
-
|
|
744
|
-
.badge {
|
|
745
|
-
display: inline-flex; align-items: center; gap: 5px;
|
|
746
|
-
font-size: 10.5px; padding: 1px 7px; border-radius: 999px;
|
|
747
|
-
background: var(--chip); color: var(--fg-dim);
|
|
748
|
-
}
|
|
749
|
-
.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
750
|
-
.badge.k-decision { color: var(--k-decision); }
|
|
751
|
-
.badge.k-fact { color: var(--k-fact); }
|
|
752
|
-
.badge.k-open_question { color: var(--k-open_question); }
|
|
753
|
-
.badge.k-preference { color: var(--k-preference); }
|
|
754
|
-
.badge.k-note { color: var(--k-note); }
|
|
755
|
-
.badge.k-entity { color: var(--entity); }
|
|
756
|
-
.badge.plain::before { display: none; }
|
|
757
|
-
.badge.warn { color: var(--k-open_question); }
|
|
758
|
-
.badge[hidden] { display: none; }
|
|
759
|
-
|
|
760
|
-
#panel {
|
|
761
|
-
position: absolute; top: 14px; right: 14px; width: 344px;
|
|
762
|
-
max-height: calc(100% - 28px); overflow: auto;
|
|
763
|
-
padding: 16px 18px 18px; border-radius: 14px;
|
|
764
|
-
border: 1px solid var(--line); background: var(--panel);
|
|
765
|
-
box-shadow: var(--shadow); backdrop-filter: blur(12px);
|
|
766
|
-
z-index: 5;
|
|
767
|
-
}
|
|
768
|
-
#panel[hidden] { display: none; }
|
|
769
|
-
#panel .eyebrow { font-size: 10.5px; letter-spacing: 0.09em; text-transform: uppercase; color: var(--fg-dim); }
|
|
770
|
-
#panel h2 { font-size: 14.5px; font-weight: 620; margin: 4px 0 10px; line-height: 1.45; }
|
|
771
|
-
#panel .row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
772
|
-
#panel dl { display: grid; grid-template-columns: 74px 1fr; gap: 3px 10px; margin: 0 0 12px; font-size: 11.5px; }
|
|
773
|
-
#panel dt { color: var(--fg-dim); }
|
|
774
|
-
#panel dd { margin: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
|
775
|
-
#panel .body { font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0 0 14px; color: var(--fg); }
|
|
776
|
-
#panel h3 { font-size: 10.5px; letter-spacing: 0.09em; text-transform: uppercase; color: var(--fg-dim); margin: 0 0 7px; font-weight: 600; }
|
|
777
|
-
#panel .chips { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 14px; }
|
|
778
|
-
#panel .chip {
|
|
779
|
-
font-size: 11px; padding: 2px 8px; border-radius: 999px; cursor: pointer;
|
|
780
|
-
border: 1px solid var(--line); background: var(--chip); color: var(--fg);
|
|
781
|
-
}
|
|
782
|
-
#panel .chip:hover { border-color: var(--mint); color: var(--mint); }
|
|
783
|
-
#panel .chip.static { cursor: default; }
|
|
784
|
-
#panel .chip.static:hover { border-color: var(--line); color: var(--fg); }
|
|
785
|
-
#panel ul.mentions { list-style: none; margin: 0 0 10px; padding: 0; display: grid; gap: 5px; }
|
|
786
|
-
#panel ul.mentions li {
|
|
787
|
-
display: grid; grid-template-columns: 74px 1fr; gap: 8px; font-size: 11.5px;
|
|
788
|
-
padding: 4px 6px; border-radius: 7px; cursor: pointer; color: var(--fg);
|
|
789
|
-
}
|
|
790
|
-
#panel ul.mentions li:hover { background: var(--chip); }
|
|
791
|
-
#panel ul.mentions span.d { color: var(--fg-dim); font-variant-numeric: tabular-nums; }
|
|
792
|
-
#panel .close {
|
|
793
|
-
position: absolute; top: 10px; right: 10px; width: 24px; min-width: 24px; height: 24px;
|
|
794
|
-
padding: 0; line-height: 1; font-size: 15px; border-radius: 7px;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
/* The legend and the keyboard hint live in a status bar *below* the canvas, so
|
|
798
|
-
no amount of graph can ever end up behind them. */
|
|
799
|
-
footer {
|
|
800
|
-
display: flex; align-items: center; justify-content: space-between; gap: 10px 18px;
|
|
801
|
-
flex-wrap: wrap; padding: 7px 16px 8px;
|
|
802
|
-
border-top: 1px solid var(--line);
|
|
803
|
-
background: var(--bg-1);
|
|
804
|
-
font-size: 10.5px; color: var(--fg-dim);
|
|
805
|
-
}
|
|
806
|
-
#legend { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
|
807
|
-
#legend .item { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
|
808
|
-
#legend .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex: none; }
|
|
809
|
-
#legend .dot.hollow { background: none; border: 1px dashed currentColor; border-radius: 50%; }
|
|
810
|
-
#legend .dot.ringed { background: none; border: 1px solid currentColor; box-shadow: 0 0 0 2px rgba(110, 231, 183, 0.25); }
|
|
811
|
-
#legend .i-decision { color: var(--k-decision); }
|
|
812
|
-
#legend .i-fact { color: var(--k-fact); }
|
|
813
|
-
#legend .i-open_question { color: var(--k-open_question); }
|
|
814
|
-
#legend .i-preference { color: var(--k-preference); }
|
|
815
|
-
#legend .i-note { color: var(--k-note); }
|
|
816
|
-
#legend .i-entity { color: var(--entity); }
|
|
817
|
-
#legend .i-mint { color: var(--mint); }
|
|
818
|
-
|
|
819
|
-
#hint { opacity: 0.85; text-align: right; }
|
|
820
|
-
@media (max-width: 860px) {
|
|
821
|
-
#hint { display: none; }
|
|
822
|
-
#panel { width: auto; left: 14px; }
|
|
823
|
-
input[type="search"] { width: 130px; }
|
|
824
|
-
}
|
|
825
|
-
@media (prefers-reduced-motion: reduce) {
|
|
826
|
-
.edge.lit, .node.hub .ring { animation: none; }
|
|
827
|
-
}
|
|
828
|
-
`
|
|
829
|
-
|
|
830
|
-
const BODY = `
|
|
831
|
-
<div id="app">
|
|
832
|
-
<header>
|
|
833
|
-
<div class="brand"><h1>记忆图</h1><span class="en">memory graph</span></div>
|
|
834
|
-
<div class="counts" id="counts"></div>
|
|
835
|
-
<div class="spacer"></div>
|
|
836
|
-
<div class="controls">
|
|
837
|
-
<input id="search" type="search" placeholder="搜索标题 / 实体 / id" autocomplete="off" spellcheck="false">
|
|
838
|
-
<select id="project" title="按项目过滤"></select>
|
|
839
|
-
<div class="zoom">
|
|
840
|
-
<button id="theme" title="主题:跟随系统 / 深色 / 浅色">◐</button>
|
|
841
|
-
<button id="zoom-in" title="放大">+</button>
|
|
842
|
-
<button id="zoom-out" title="缩小">−</button>
|
|
843
|
-
<button id="zoom-fit" title="复位 (f)">fit</button>
|
|
844
|
-
</div>
|
|
845
|
-
</div>
|
|
846
|
-
</header>
|
|
847
|
-
<main>
|
|
848
|
-
<div id="stage">
|
|
849
|
-
<svg id="canvas" viewBox="0 0 1400 860" preserveAspectRatio="xMidYMid meet" role="img" aria-label="记忆图">
|
|
850
|
-
<defs id="defs"></defs>
|
|
851
|
-
<g id="viewport">
|
|
852
|
-
<g id="edges"></g>
|
|
853
|
-
<g id="nodes"></g>
|
|
854
|
-
<g id="labels"></g>
|
|
855
|
-
</g>
|
|
856
|
-
</svg>
|
|
857
|
-
<div id="tooltip"></div>
|
|
858
|
-
<aside id="panel" hidden></aside>
|
|
859
|
-
<div id="empty" hidden>
|
|
860
|
-
<h2>记忆库还是空的</h2>
|
|
861
|
-
<p>这张图是从 <code>cards/</code> 和 <code>entities/</code> 读出来的。等 agent 写下第一张记忆卡片——或跑一次蒸馏——再重新生成一次 <code>graph.html</code>,这里就会长出第一颗神经元。</p>
|
|
862
|
-
</div>
|
|
863
|
-
<noscript><p style="padding:24px">这个页面需要 JavaScript 才能画图。</p></noscript>
|
|
864
|
-
</div>
|
|
865
|
-
</main>
|
|
866
|
-
<footer>
|
|
867
|
-
<div id="legend"></div>
|
|
868
|
-
<div id="hint">滚轮缩放 · 拖拽平移 · 点击节点看详情 · Esc 清除 · / 搜索</div>
|
|
869
|
-
</footer>
|
|
870
|
-
</div>
|
|
871
|
-
`
|
|
872
|
-
|
|
873
|
-
/* ---------------------------------------------------------------- browser */
|
|
874
|
-
|
|
875
|
-
/**
|
|
876
|
-
* Everything below runs in the browser. Written with `var` and string
|
|
877
|
-
* concatenation on purpose: it is inlined into a template literal here, so it
|
|
878
|
-
* must not contain backticks or `${` of its own.
|
|
879
|
-
*/
|
|
880
|
-
function clientScript(dataJson) {
|
|
881
|
-
return `
|
|
882
|
-
(function () {
|
|
883
|
-
'use strict'
|
|
884
|
-
|
|
885
|
-
var DATA = ${dataJson};
|
|
886
|
-
var canvas = document.getElementById('canvas');
|
|
887
|
-
var NS = canvas.namespaceURI;
|
|
888
|
-
var viewport = document.getElementById('viewport');
|
|
889
|
-
var edgeLayer = document.getElementById('edges');
|
|
890
|
-
var nodeLayer = document.getElementById('nodes');
|
|
891
|
-
var labelLayer = document.getElementById('labels');
|
|
892
|
-
var stage = document.getElementById('stage');
|
|
893
|
-
var tooltip = document.getElementById('tooltip');
|
|
894
|
-
var panel = document.getElementById('panel');
|
|
895
|
-
var emptyBox = document.getElementById('empty');
|
|
896
|
-
var legendBox = document.getElementById('legend');
|
|
897
|
-
var countsBox = document.getElementById('counts');
|
|
898
|
-
var searchBox = document.getElementById('search');
|
|
899
|
-
var projectBox = document.getElementById('project');
|
|
900
|
-
|
|
901
|
-
var nodes = DATA.nodes;
|
|
902
|
-
var byKey = {};
|
|
903
|
-
var neighbors = {};
|
|
904
|
-
var selected = null;
|
|
905
|
-
var view = { k: 1, x: 0, y: 0 };
|
|
906
|
-
var GRADIENT_KINDS = ['decision', 'fact', 'open_question', 'preference', 'note', 'entity'];
|
|
907
|
-
|
|
908
|
-
/* ------------------------------------------------------------ theme */
|
|
909
|
-
|
|
910
|
-
var THEME_KEY = 'dsh-memory-graph-theme';
|
|
911
|
-
var themeButton = document.getElementById('theme');
|
|
912
|
-
var gradients = {};
|
|
913
|
-
|
|
914
|
-
function storedTheme() {
|
|
915
|
-
try { return localStorage.getItem(THEME_KEY) || 'auto'; } catch (error) { return 'auto' }
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
function gradientStops() {
|
|
919
|
-
var styles = getComputedStyle(document.documentElement);
|
|
920
|
-
GRADIENT_KINDS.forEach(function (kind) {
|
|
921
|
-
var color = styles.getPropertyValue('--k-' + kind).trim() || '#94a3b8';
|
|
922
|
-
var stops = gradients[kind];
|
|
923
|
-
stops[0].setAttribute('stop-color', color);
|
|
924
|
-
stops[1].setAttribute('stop-color', color);
|
|
925
|
-
stops[2].setAttribute('stop-color', color);
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
function applyTheme(mode) {
|
|
930
|
-
if (mode === 'auto') document.documentElement.removeAttribute('data-theme');
|
|
931
|
-
else document.documentElement.setAttribute('data-theme', mode);
|
|
932
|
-
themeButton.textContent = mode === 'dark' ? '☾' : mode === 'light' ? '☀' : '◐';
|
|
933
|
-
themeButton.title = '主题:' + (mode === 'auto' ? '跟随系统' : mode === 'dark' ? '深色' : '浅色');
|
|
934
|
-
gradientStops();
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
themeButton.addEventListener('click', function () {
|
|
938
|
-
var next = storedTheme() === 'auto' ? 'dark' : storedTheme() === 'dark' ? 'light' : 'auto';
|
|
939
|
-
try { localStorage.setItem(THEME_KEY, next); } catch (error) { /* private mode */ }
|
|
940
|
-
applyTheme(next);
|
|
941
|
-
});
|
|
942
|
-
|
|
943
|
-
if (window.matchMedia) {
|
|
944
|
-
var media = window.matchMedia('(prefers-color-scheme: light)');
|
|
945
|
-
var onChange = function () { if (storedTheme() === 'auto') gradientStops(); };
|
|
946
|
-
if (media.addEventListener) media.addEventListener('change', onChange);
|
|
947
|
-
else if (media.addListener) media.addListener(onChange);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
nodes.forEach(function (node) {
|
|
952
|
-
byKey[node.key] = node;
|
|
953
|
-
neighbors[node.key] = {};
|
|
954
|
-
});
|
|
955
|
-
DATA.links.forEach(function (link) {
|
|
956
|
-
if (!byKey[link.s] || !byKey[link.t]) return;
|
|
957
|
-
neighbors[link.s][link.t] = true;
|
|
958
|
-
neighbors[link.t][link.s] = true;
|
|
959
|
-
});
|
|
960
|
-
|
|
961
|
-
function el(name, attrs) {
|
|
962
|
-
var node = document.createElementNS(NS, name);
|
|
963
|
-
if (attrs) { for (var key in attrs) node.setAttribute(key, attrs[key]); }
|
|
964
|
-
return node;
|
|
965
|
-
}
|
|
966
|
-
function h(tag, cls, text) {
|
|
967
|
-
var node = document.createElement(tag);
|
|
968
|
-
if (cls) node.className = cls;
|
|
969
|
-
if (text !== undefined && text !== null) node.textContent = text;
|
|
970
|
-
return node;
|
|
971
|
-
}
|
|
972
|
-
function fmt(n) { return Math.round(n * 10) / 10; }
|
|
973
|
-
|
|
974
|
-
// One soft radial glow per kind: the cell body reads as light, not as a dot.
|
|
975
|
-
var defs = document.getElementById('defs');
|
|
976
|
-
GRADIENT_KINDS.forEach(function (kind) {
|
|
977
|
-
var gradient = el('radialGradient', { id: 'glow-' + kind });
|
|
978
|
-
var stops = [];
|
|
979
|
-
[[0, 0.42], [0.4, 0.13], [1, 0]].forEach(function (spec) {
|
|
980
|
-
var stop = el('stop', { offset: String(spec[0]), 'stop-opacity': String(spec[1]) });
|
|
981
|
-
gradient.appendChild(stop);
|
|
982
|
-
stops.push(stop);
|
|
983
|
-
});
|
|
984
|
-
defs.appendChild(gradient);
|
|
985
|
-
gradients[kind] = stops;
|
|
986
|
-
});
|
|
987
|
-
// The address bar can pin a theme and open one node: #dark, #light, and
|
|
988
|
-
// #dark&<card id or entity slug>. Screenshots use it, and so does anything
|
|
989
|
-
// that wants to link straight to one cell.
|
|
990
|
-
var initialTheme = storedTheme();
|
|
991
|
-
var pendingNode = '';
|
|
992
|
-
var fragment = String(location.hash || '').replace(/^#/, '');
|
|
993
|
-
try {
|
|
994
|
-
fragment = decodeURIComponent(fragment);
|
|
995
|
-
} catch (error) {
|
|
996
|
-
// A stray percent sign is not worth a blank page.
|
|
997
|
-
}
|
|
998
|
-
fragment.split('&').forEach(function (part) {
|
|
999
|
-
if (part === 'dark' || part === 'light') initialTheme = part;
|
|
1000
|
-
else if (part) pendingNode = part;
|
|
1001
|
-
});
|
|
1002
|
-
applyTheme(initialTheme);
|
|
1003
|
-
|
|
1004
|
-
/* ------------------------------------------------------------ header */
|
|
1005
|
-
|
|
1006
|
-
var c = DATA.counts;
|
|
1007
|
-
countsBox.textContent = '';
|
|
1008
|
-
[['卡片', c.cards], ['实体', c.entities], ['项目', c.projects]].forEach(function (pair, i) {
|
|
1009
|
-
if (i > 0) countsBox.appendChild(document.createTextNode(' · '));
|
|
1010
|
-
var b = h('b', null, String(pair[1]));
|
|
1011
|
-
countsBox.appendChild(document.createTextNode(pair[0] + ' '));
|
|
1012
|
-
countsBox.appendChild(b);
|
|
1013
|
-
});
|
|
1014
|
-
if (c.superseded) countsBox.appendChild(document.createTextNode(' · 已替换 ' + c.superseded));
|
|
1015
|
-
if (c.retracted) countsBox.appendChild(document.createTextNode(' · 已撤回 ' + c.retracted));
|
|
1016
|
-
if (DATA.generatedAt) {
|
|
1017
|
-
countsBox.appendChild(document.createTextNode(' · 生成于 ' + String(DATA.generatedAt).replace('T', ' ').slice(0, 16)));
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
/* ------------------------------------------------------------ legend */
|
|
1021
|
-
|
|
1022
|
-
var LEGEND = [
|
|
1023
|
-
['decision', '决策'], ['fact', '事实'], ['open_question', '待解'],
|
|
1024
|
-
['preference', '偏好'], ['note', '记录']
|
|
1025
|
-
];
|
|
1026
|
-
LEGEND.forEach(function (pair) {
|
|
1027
|
-
var item = h('div', 'item');
|
|
1028
|
-
item.appendChild(h('span', 'dot i-' + pair[0]));
|
|
1029
|
-
item.appendChild(h('span', null, pair[1]));
|
|
1030
|
-
legendBox.appendChild(item);
|
|
1031
|
-
});
|
|
1032
|
-
var ent = h('div', 'item');
|
|
1033
|
-
ent.appendChild(h('span', 'dot i-entity'));
|
|
1034
|
-
ent.appendChild(h('span', null, '实体(越大连得越多)'));
|
|
1035
|
-
legendBox.appendChild(ent);
|
|
1036
|
-
var hub = h('div', 'item');
|
|
1037
|
-
hub.appendChild(h('span', 'dot hollow i-mint'));
|
|
1038
|
-
hub.appendChild(h('span', null, '跨项目枢纽'));
|
|
1039
|
-
legendBox.appendChild(hub);
|
|
1040
|
-
var sup = h('div', 'item');
|
|
1041
|
-
sup.appendChild(h('span', 'ringed i-mint'));
|
|
1042
|
-
sup.appendChild(h('span', null, '虚线 = 已被替换'));
|
|
1043
|
-
legendBox.appendChild(sup);
|
|
1044
|
-
|
|
1045
|
-
if (nodes.length === 0) {
|
|
1046
|
-
emptyBox.hidden = false;
|
|
1047
|
-
return;
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
/* ------------------------------------------------------------ edges */
|
|
1051
|
-
|
|
1052
|
-
function hashNumber(text) {
|
|
1053
|
-
var sum = 0;
|
|
1054
|
-
for (var i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 100003;
|
|
1055
|
-
return sum;
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
var edgeEls = [];
|
|
1059
|
-
DATA.links.forEach(function (link) {
|
|
1060
|
-
var a = byKey[link.s];
|
|
1061
|
-
var b = byKey[link.t];
|
|
1062
|
-
if (!a || !b) return;
|
|
1063
|
-
var dx = b.x - a.x;
|
|
1064
|
-
var dy = b.y - a.y;
|
|
1065
|
-
var len = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
1066
|
-
var ux = dx / len;
|
|
1067
|
-
var uy = dy / len;
|
|
1068
|
-
var bend = Math.min(42, len * 0.16) * (hashNumber(a.key + b.key) % 2 ? 1 : -1);
|
|
1069
|
-
// Start and end just outside the node, so lines do not pierce the halo.
|
|
1070
|
-
var ax = a.x + ux * (a.r + 1.5);
|
|
1071
|
-
var ay = a.y + uy * (a.r + 1.5);
|
|
1072
|
-
var bx = b.x - ux * (b.r + 1.5);
|
|
1073
|
-
var by = b.y - uy * (b.r + 1.5);
|
|
1074
|
-
var mx = (ax + bx) / 2 - uy * bend;
|
|
1075
|
-
var my = (ay + by) / 2 + ux * bend;
|
|
1076
|
-
var d = 'M' + fmt(ax) + ' ' + fmt(ay) + 'Q' + fmt(mx) + ' ' + fmt(my) + ' ' + fmt(bx) + ' ' + fmt(by);
|
|
1077
|
-
var group = el('g', { 'class': 'edge-group' + (link.k === 'supersede' ? ' supersede' : '') });
|
|
1078
|
-
// Two passes: a wide faint halo and the axon itself. Cheaper than a filter
|
|
1079
|
-
// and it keeps the line glowing without touching the text layer.
|
|
1080
|
-
group.appendChild(el('path', { 'class': 'edge-glow', d: d }));
|
|
1081
|
-
group.appendChild(el('path', { 'class': 'edge', d: d }));
|
|
1082
|
-
edgeLayer.appendChild(group);
|
|
1083
|
-
edgeEls.push({ g: group, a: link.s, b: link.t });
|
|
1084
|
-
});
|
|
1085
|
-
|
|
1086
|
-
/* ------------------------------------------------------------ nodes */
|
|
1087
|
-
|
|
1088
|
-
nodes.forEach(function (node) {
|
|
1089
|
-
var cls = 'node ' + node.type + ' k-' + node.kind;
|
|
1090
|
-
if (node.hub) cls += ' hub';
|
|
1091
|
-
if (node.status === 'superseded') cls += ' superseded';
|
|
1092
|
-
if (node.status === 'retracted') cls += ' retracted';
|
|
1093
|
-
node.baseClass = cls;
|
|
1094
|
-
|
|
1095
|
-
var group = el('g', { 'class': cls, 'tabindex': '0', role: 'button' });
|
|
1096
|
-
group.appendChild(el('circle', {
|
|
1097
|
-
'class': 'halo',
|
|
1098
|
-
cx: node.x,
|
|
1099
|
-
cy: node.y,
|
|
1100
|
-
r: fmt(node.r * node.haloScale),
|
|
1101
|
-
fill: 'url(#glow-' + node.kind + ')'
|
|
1102
|
-
}));
|
|
1103
|
-
if (node.hub) {
|
|
1104
|
-
group.appendChild(el('circle', { 'class': 'ring', cx: node.x, cy: node.y, r: fmt(node.r + 5) }));
|
|
1105
|
-
}
|
|
1106
|
-
group.appendChild(el('circle', { 'class': 'core', cx: node.x, cy: node.y, r: node.r }));
|
|
1107
|
-
if (node.type === 'entity' && node.r >= 10) {
|
|
1108
|
-
group.appendChild(el('circle', { 'class': 'nucleus', cx: node.x, cy: node.y, r: fmt(node.r * 0.42) }));
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
var title = el('title');
|
|
1112
|
-
title.textContent = node.full || node.label;
|
|
1113
|
-
group.appendChild(title);
|
|
1114
|
-
|
|
1115
|
-
group.addEventListener('pointerenter', function (event) { hover(node, event); });
|
|
1116
|
-
group.addEventListener('pointerleave', function () { unhover(); });
|
|
1117
|
-
group.addEventListener('focus', function () { hover(node, null); });
|
|
1118
|
-
group.addEventListener('blur', function () { unhover(); });
|
|
1119
|
-
group.addEventListener('click', function (event) {
|
|
1120
|
-
event.stopPropagation();
|
|
1121
|
-
select(selected === node.key ? null : node.key);
|
|
1122
|
-
});
|
|
1123
|
-
group.addEventListener('keydown', function (event) {
|
|
1124
|
-
if (event.key === 'Enter' || event.key === ' ') {
|
|
1125
|
-
event.preventDefault();
|
|
1126
|
-
select(selected === node.key ? null : node.key);
|
|
1127
|
-
}
|
|
1128
|
-
});
|
|
1129
|
-
|
|
1130
|
-
nodeLayer.appendChild(group);
|
|
1131
|
-
|
|
1132
|
-
var label = el('text', {
|
|
1133
|
-
x: node.labelAt.x,
|
|
1134
|
-
y: node.labelAt.y,
|
|
1135
|
-
'text-anchor': node.labelAt.anchor,
|
|
1136
|
-
'dominant-baseline': 'central',
|
|
1137
|
-
'class': 'lbl ' + node.type + (node.labelAt.on ? '' : ' off')
|
|
1138
|
-
});
|
|
1139
|
-
label.textContent = node.label;
|
|
1140
|
-
labelLayer.appendChild(label);
|
|
1141
|
-
|
|
1142
|
-
node.el = group;
|
|
1143
|
-
node.lblEl = label;
|
|
1144
|
-
node.lblOn = node.labelAt.on;
|
|
1145
|
-
});
|
|
1146
|
-
|
|
1147
|
-
/* ------------------------------------------------------------ tooltip */
|
|
1148
|
-
|
|
1149
|
-
function setTooltip(node, event) {
|
|
1150
|
-
tooltip.textContent = '';
|
|
1151
|
-
var kind = h('div', 'tt-kind');
|
|
1152
|
-
if (node.type === 'entity') {
|
|
1153
|
-
kind.appendChild(h('span', 'badge k-entity', '实体 · ' + (node.etype || 'concept')));
|
|
1154
|
-
} else {
|
|
1155
|
-
kind.appendChild(h('span', 'badge k-' + node.kind, DATA.kinds[node.kind] || node.kind));
|
|
1156
|
-
if (node.status !== 'active') {
|
|
1157
|
-
kind.appendChild(document.createTextNode(' '));
|
|
1158
|
-
kind.appendChild(h('span', 'badge warn plain', node.status === 'superseded' ? '已替换' : '已撤回'));
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
tooltip.appendChild(kind);
|
|
1162
|
-
tooltip.appendChild(h('div', 'tt-title', node.full || node.label));
|
|
1163
|
-
|
|
1164
|
-
if (node.type === 'entity') {
|
|
1165
|
-
var cards = Object.keys(neighbors[node.key]).filter(function (key) { return key.indexOf('c:') === 0; });
|
|
1166
|
-
tooltip.appendChild(h('div', 'tt-snip', cards.length + ' 张卡片 · ' + (node.projects.length ? node.projects.join(' / ') : '未归档')));
|
|
1167
|
-
tooltip.appendChild(h('div', 'tt-meta', 'first ' + (node.firstSeen || '—') + ' · last ' + (node.lastSeen || '—')));
|
|
1168
|
-
} else {
|
|
1169
|
-
if (node.snippet) tooltip.appendChild(h('div', 'tt-snip', node.snippet));
|
|
1170
|
-
tooltip.appendChild(h('div', 'tt-meta', node.id + ' · ' + node.project + ' · ' + node.created));
|
|
1171
|
-
}
|
|
1172
|
-
tooltip.classList.add('on');
|
|
1173
|
-
position(node, event);
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
function position(node, event) {
|
|
1177
|
-
var rect = stage.getBoundingClientRect();
|
|
1178
|
-
var x;
|
|
1179
|
-
var y;
|
|
1180
|
-
if (event && event.clientX) {
|
|
1181
|
-
x = event.clientX - rect.left + 16;
|
|
1182
|
-
y = event.clientY - rect.top + 14;
|
|
1183
|
-
} else {
|
|
1184
|
-
var box = node.el.getBoundingClientRect();
|
|
1185
|
-
x = box.left - rect.left + box.width / 2 + 14;
|
|
1186
|
-
y = box.top - rect.top - 8;
|
|
1187
|
-
}
|
|
1188
|
-
var w = tooltip.offsetWidth;
|
|
1189
|
-
var hh = tooltip.offsetHeight;
|
|
1190
|
-
if (x + w > rect.width - 10) x = Math.max(10, x - w - 30);
|
|
1191
|
-
if (y + hh > rect.height - 10) y = Math.max(10, y - hh - 24);
|
|
1192
|
-
tooltip.style.left = x + 'px';
|
|
1193
|
-
tooltip.style.top = y + 'px';
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
var hovered = null;
|
|
1197
|
-
function hover(node, event) {
|
|
1198
|
-
hovered = node;
|
|
1199
|
-
setTooltip(node, event);
|
|
1200
|
-
if (!node.lblOn) node.lblEl.classList.remove('off');
|
|
1201
|
-
}
|
|
1202
|
-
function unhover() {
|
|
1203
|
-
hovered = null;
|
|
1204
|
-
tooltip.classList.remove('on');
|
|
1205
|
-
refresh();
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
/* ------------------------------------------------------------ panel */
|
|
1209
|
-
|
|
1210
|
-
function badge(kindClass, text, plain) {
|
|
1211
|
-
return h('span', 'badge ' + kindClass + (plain ? ' plain' : ''), text);
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
function chip(text, onclick) {
|
|
1215
|
-
var node = h('span', 'chip' + (onclick ? '' : ' static'), text);
|
|
1216
|
-
if (onclick) node.addEventListener('click', onclick);
|
|
1217
|
-
return node;
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
function renderPanel(node) {
|
|
1221
|
-
panel.textContent = '';
|
|
1222
|
-
var close = h('button', 'close', '×');
|
|
1223
|
-
close.title = '关闭 (Esc)';
|
|
1224
|
-
close.addEventListener('click', function () { select(null); });
|
|
1225
|
-
panel.appendChild(close);
|
|
1226
|
-
|
|
1227
|
-
if (node.type === 'entity') {
|
|
1228
|
-
panel.appendChild(h('div', 'eyebrow', '实体 entity · ' + (node.etype || 'concept')));
|
|
1229
|
-
panel.appendChild(h('h2', null, node.full || node.label));
|
|
1230
|
-
var row = h('div', 'row');
|
|
1231
|
-
if (node.hub) row.appendChild(badge('k-mint', '跨项目枢纽'));
|
|
1232
|
-
row.appendChild(badge('k-entity', Object.keys(neighbors[node.key]).length + ' 条连线'));
|
|
1233
|
-
panel.appendChild(row);
|
|
1234
|
-
|
|
1235
|
-
var dl = h('dl');
|
|
1236
|
-
var pairs = [
|
|
1237
|
-
['首次出现', node.firstSeen || '—'],
|
|
1238
|
-
['最近出现', node.lastSeen || '—'],
|
|
1239
|
-
['别名', (node.aliases || []).join(' · ') || '—'],
|
|
1240
|
-
['项目', (node.projects || []).join(' · ') || '—'],
|
|
1241
|
-
['id', node.slug]
|
|
1242
|
-
];
|
|
1243
|
-
pairs.forEach(function (pair) {
|
|
1244
|
-
dl.appendChild(h('dt', null, pair[0]));
|
|
1245
|
-
dl.appendChild(h('dd', null, pair[1]));
|
|
1246
|
-
});
|
|
1247
|
-
panel.appendChild(dl);
|
|
1248
|
-
|
|
1249
|
-
if (node.mentions && node.mentions.length) {
|
|
1250
|
-
panel.appendChild(h('h3', null, '提及时间线'));
|
|
1251
|
-
var list = h('ul', 'mentions');
|
|
1252
|
-
node.mentions.forEach(function (mention) {
|
|
1253
|
-
var li = h('li');
|
|
1254
|
-
li.appendChild(h('span', 'd', mention.d));
|
|
1255
|
-
li.appendChild(h('span', null, mention.note));
|
|
1256
|
-
var key = 'c:' + mention.cardId;
|
|
1257
|
-
if (byKey[key]) li.addEventListener('click', function () { select(key); });
|
|
1258
|
-
list.appendChild(li);
|
|
1259
|
-
});
|
|
1260
|
-
panel.appendChild(list);
|
|
1261
|
-
}
|
|
1262
|
-
panel.hidden = false;
|
|
1263
|
-
return;
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
panel.appendChild(h('div', 'eyebrow', '记忆卡片 memory card'));
|
|
1267
|
-
panel.appendChild(h('h2', null, node.full || node.label));
|
|
1268
|
-
var badges = h('div', 'row');
|
|
1269
|
-
badges.appendChild(badge('k-' + node.kind, DATA.kinds[node.kind] || node.kind));
|
|
1270
|
-
if (node.status !== 'active') {
|
|
1271
|
-
badges.appendChild(badge('warn', node.status === 'superseded' ? '已替换 superseded' : '已撤回 retracted', true));
|
|
1272
|
-
}
|
|
1273
|
-
badges.appendChild(badge('plain', node.confidence, true));
|
|
1274
|
-
panel.appendChild(badges);
|
|
1275
|
-
|
|
1276
|
-
var meta = h('dl');
|
|
1277
|
-
[['项目', node.project], ['日期', node.created], ['id', node.id]].forEach(function (pair) {
|
|
1278
|
-
meta.appendChild(h('dt', null, pair[0]));
|
|
1279
|
-
meta.appendChild(h('dd', null, pair[1]));
|
|
1280
|
-
});
|
|
1281
|
-
panel.appendChild(meta);
|
|
1282
|
-
|
|
1283
|
-
if (node.body) panel.appendChild(h('p', 'body', node.body));
|
|
1284
|
-
|
|
1285
|
-
if (node.entities && node.entities.length) {
|
|
1286
|
-
panel.appendChild(h('h3', null, '提到的实体'));
|
|
1287
|
-
var chips = h('div', 'chips');
|
|
1288
|
-
node.entities.forEach(function (entity) {
|
|
1289
|
-
var key = 'e:' + entity.slug;
|
|
1290
|
-
chips.appendChild(chip(entity.name, byKey[key] ? function () { select(key); } : null));
|
|
1291
|
-
});
|
|
1292
|
-
panel.appendChild(chips);
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
var chain = []
|
|
1296
|
-
if (node.supersedes && node.supersedes.length) chain.push(['替换了', node.supersedes]);
|
|
1297
|
-
if (node.supersededBy) chain.push(['被替换为', [node.supersededBy]]);
|
|
1298
|
-
if (chain.length) {
|
|
1299
|
-
panel.appendChild(h('h3', null, '替换链'));
|
|
1300
|
-
var chainBox = h('div', 'chips');
|
|
1301
|
-
chain.forEach(function (entry) {
|
|
1302
|
-
entry[1].forEach(function (id) {
|
|
1303
|
-
var other = byKey['c:' + id];
|
|
1304
|
-
var label = entry[0] + ' ' + (other ? (other.full || other.label) : id);
|
|
1305
|
-
chainBox.appendChild(chip(label, other ? function () { select(other.key); } : null));
|
|
1306
|
-
});
|
|
1307
|
-
});
|
|
1308
|
-
panel.appendChild(chainBox);
|
|
1309
|
-
}
|
|
1310
|
-
panel.hidden = false;
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
/* ------------------------------------------------------------ state */
|
|
1314
|
-
|
|
1315
|
-
function matches(node, q) {
|
|
1316
|
-
if (q === '') return true;
|
|
1317
|
-
if (String(node.label).toLowerCase().indexOf(q) !== -1) return true;
|
|
1318
|
-
if (String(node.full).toLowerCase().indexOf(q) !== -1) return true;
|
|
1319
|
-
if (node.type === 'card') {
|
|
1320
|
-
if (String(node.id).toLowerCase().indexOf(q) !== -1) return true;
|
|
1321
|
-
for (var i = 0; i < node.entities.length; i++) {
|
|
1322
|
-
if (String(node.entities[i].name).toLowerCase().indexOf(q) !== -1) return true;
|
|
1323
|
-
}
|
|
1324
|
-
} else {
|
|
1325
|
-
if (String(node.slug).toLowerCase().indexOf(q) !== -1) return true;
|
|
1326
|
-
}
|
|
1327
|
-
return false;
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
function refresh() {
|
|
1331
|
-
var q = searchBox.value.trim().toLowerCase();
|
|
1332
|
-
var project = projectBox.value;
|
|
1333
|
-
var filtering = q !== '' || project !== '*';
|
|
1334
|
-
var visible = {};
|
|
1335
|
-
var hitCount = 0;
|
|
1336
|
-
|
|
1337
|
-
nodes.forEach(function (node) {
|
|
1338
|
-
var cls = node.baseClass;
|
|
1339
|
-
var show = true;
|
|
1340
|
-
var hit = false;
|
|
1341
|
-
if (selected) {
|
|
1342
|
-
if (node.key === selected || neighbors[selected][node.key]) {
|
|
1343
|
-
cls += ' lit';
|
|
1344
|
-
show = true;
|
|
1345
|
-
} else {
|
|
1346
|
-
cls += ' dim';
|
|
1347
|
-
show = false;
|
|
1348
|
-
}
|
|
1349
|
-
} else if (filtering) {
|
|
1350
|
-
var okProject = project === '*' || node.projects.indexOf(project) !== -1;
|
|
1351
|
-
var okQuery = matches(node, q);
|
|
1352
|
-
if (okProject && okQuery) {
|
|
1353
|
-
if (q !== '') { cls += ' hit'; hit = true; hitCount++; }
|
|
1354
|
-
} else {
|
|
1355
|
-
cls += ' dim';
|
|
1356
|
-
show = false;
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
if (node.key === selected) cls += ' sel';
|
|
1360
|
-
node.el.setAttribute('class', cls);
|
|
1361
|
-
visible[node.key] = show;
|
|
1362
|
-
|
|
1363
|
-
var labelCls = 'lbl ' + node.type + (show && (node.lblOn || hit || node.key === selected) ? '' : ' off') + (show ? '' : ' dim');
|
|
1364
|
-
node.lblEl.setAttribute('class', labelCls);
|
|
1365
|
-
});
|
|
1366
|
-
|
|
1367
|
-
edgeEls.forEach(function (edge) {
|
|
1368
|
-
var cls = 'edge-group' + (String(edge.g.getAttribute('class')).indexOf('supersede') !== -1 ? ' supersede' : '');
|
|
1369
|
-
if (selected) {
|
|
1370
|
-
if (visible[edge.a] && visible[edge.b]) cls += ' lit';
|
|
1371
|
-
else cls += ' dim';
|
|
1372
|
-
} else if (!visible[edge.a] || !visible[edge.b]) {
|
|
1373
|
-
cls += ' dim';
|
|
1374
|
-
}
|
|
1375
|
-
edge.g.setAttribute('class', cls);
|
|
1376
|
-
});
|
|
1377
|
-
|
|
1378
|
-
var label = document.getElementById('match-bar');
|
|
1379
|
-
if (filtering && q !== '') {
|
|
1380
|
-
if (!label) {
|
|
1381
|
-
label = h('div', 'badge plain');
|
|
1382
|
-
label.id = 'match-bar';
|
|
1383
|
-
countsBox.parentNode.insertBefore(label, countsBox.nextSibling);
|
|
1384
|
-
}
|
|
1385
|
-
label.textContent = '匹配 ' + hitCount + ' / ' + nodes.length;
|
|
1386
|
-
label.hidden = false;
|
|
1387
|
-
} else if (label) {
|
|
1388
|
-
label.hidden = true;
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
function select(key) {
|
|
1393
|
-
selected = key;
|
|
1394
|
-
if (!key) {
|
|
1395
|
-
panel.hidden = true;
|
|
1396
|
-
refresh();
|
|
1397
|
-
return;
|
|
1398
|
-
}
|
|
1399
|
-
var node = byKey[key];
|
|
1400
|
-
if (!node) return;
|
|
1401
|
-
renderPanel(node);
|
|
1402
|
-
// Bring the node into view if it sits outside the viewport.
|
|
1403
|
-
var box = node.el.getBoundingClientRect();
|
|
1404
|
-
var frame = stage.getBoundingClientRect();
|
|
1405
|
-
if (box.left < frame.left || box.right > frame.right || box.top < frame.top || box.bottom > frame.bottom) {
|
|
1406
|
-
var ctm = canvas.getScreenCTM();
|
|
1407
|
-
view.x += (frame.left + frame.width / 2 - box.left - box.width / 2) / (ctm ? ctm.a : 1);
|
|
1408
|
-
view.y += (frame.top + frame.height / 2 - box.top - box.height / 2) / (ctm ? ctm.a : 1);
|
|
1409
|
-
applyView();
|
|
1410
|
-
}
|
|
1411
|
-
refresh();
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
/* ------------------------------------------------------------ view */
|
|
1415
|
-
|
|
1416
|
-
function applyView() {
|
|
1417
|
-
viewport.setAttribute(
|
|
1418
|
-
'transform',
|
|
1419
|
-
'translate(' + fmt(view.x) + ' ' + fmt(view.y) + ') scale(' + (Math.round(view.k * 1000) / 1000) + ')'
|
|
1420
|
-
);
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
function toUser(event) {
|
|
1424
|
-
var ctm = canvas.getScreenCTM();
|
|
1425
|
-
var point = canvas.createSVGPoint();
|
|
1426
|
-
point.x = event.clientX;
|
|
1427
|
-
point.y = event.clientY;
|
|
1428
|
-
if (!ctm) return { x: 0, y: 0 };
|
|
1429
|
-
var local = point.matrixTransform(ctm.inverse());
|
|
1430
|
-
return { x: local.x, y: local.y };
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
function zoomAt(point, factor) {
|
|
1434
|
-
var k = Math.min(4.5, Math.max(0.35, view.k * factor));
|
|
1435
|
-
var ratio = k / view.k;
|
|
1436
|
-
view.x = point.x - ratio * (point.x - view.x);
|
|
1437
|
-
view.y = point.y - ratio * (point.y - view.y);
|
|
1438
|
-
view.k = k;
|
|
1439
|
-
applyView();
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
canvas.addEventListener('wheel', function (event) {
|
|
1443
|
-
event.preventDefault();
|
|
1444
|
-
zoomAt(toUser(event), Math.exp(-event.deltaY * 0.0016));
|
|
1445
|
-
}, { passive: false });
|
|
1446
|
-
|
|
1447
|
-
document.getElementById('zoom-in').addEventListener('click', function () { zoomAt({ x: 700, y: 430 }, 1.3); });
|
|
1448
|
-
document.getElementById('zoom-out').addEventListener('click', function () { zoomAt({ x: 700, y: 430 }, 1 / 1.3); });
|
|
1449
|
-
document.getElementById('zoom-fit').addEventListener('click', function () {
|
|
1450
|
-
view = { k: 1, x: 0, y: 0 };
|
|
1451
|
-
applyView();
|
|
1452
|
-
});
|
|
1453
|
-
|
|
1454
|
-
var drag = null;
|
|
1455
|
-
canvas.addEventListener('pointerdown', function (event) {
|
|
1456
|
-
if (event.target.closest && event.target.closest('.node')) return;
|
|
1457
|
-
drag = { x: event.clientX, y: event.clientY, vx: view.x, vy: view.y, moved: false };
|
|
1458
|
-
canvas.classList.add('panning');
|
|
1459
|
-
if (canvas.setPointerCapture) canvas.setPointerCapture(event.pointerId);
|
|
1460
|
-
});
|
|
1461
|
-
canvas.addEventListener('pointermove', function (event) {
|
|
1462
|
-
if (!drag) return;
|
|
1463
|
-
var ctm = canvas.getScreenCTM();
|
|
1464
|
-
var scale = ctm ? ctm.a : 1;
|
|
1465
|
-
var dx = (event.clientX - drag.x) / scale;
|
|
1466
|
-
var dy = (event.clientY - drag.y) / scale;
|
|
1467
|
-
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
|
|
1468
|
-
view.x = drag.vx + dx;
|
|
1469
|
-
view.y = drag.vy + dy;
|
|
1470
|
-
applyView();
|
|
1471
|
-
});
|
|
1472
|
-
canvas.addEventListener('pointerup', function () {
|
|
1473
|
-
var moved = drag ? drag.moved : false;
|
|
1474
|
-
drag = null;
|
|
1475
|
-
canvas.classList.remove('panning');
|
|
1476
|
-
if (!moved) select(null);
|
|
1477
|
-
});
|
|
1478
|
-
canvas.addEventListener('pointercancel', function () {
|
|
1479
|
-
drag = null;
|
|
1480
|
-
canvas.classList.remove('panning');
|
|
1481
|
-
});
|
|
1482
|
-
|
|
1483
|
-
/* ------------------------------------------------------------ filters */
|
|
1484
|
-
|
|
1485
|
-
var all = h('option', null, '全部项目 · ' + DATA.counts.cards);
|
|
1486
|
-
all.value = '*';
|
|
1487
|
-
projectBox.appendChild(all);
|
|
1488
|
-
DATA.projects.forEach(function (project) {
|
|
1489
|
-
var option = h('option', null, project.slug + ' · ' + project.count);
|
|
1490
|
-
option.value = project.slug;
|
|
1491
|
-
projectBox.appendChild(option);
|
|
1492
|
-
});
|
|
1493
|
-
projectBox.addEventListener('change', refresh);
|
|
1494
|
-
searchBox.addEventListener('input', refresh);
|
|
1495
|
-
|
|
1496
|
-
document.addEventListener('keydown', function (event) {
|
|
1497
|
-
if (event.key === 'Escape') {
|
|
1498
|
-
if (document.activeElement === searchBox && searchBox.value !== '') {
|
|
1499
|
-
searchBox.value = '';
|
|
1500
|
-
refresh();
|
|
1501
|
-
return;
|
|
1502
|
-
}
|
|
1503
|
-
select(null);
|
|
1504
|
-
searchBox.blur();
|
|
1505
|
-
} else if (event.key === '/' && document.activeElement !== searchBox) {
|
|
1506
|
-
event.preventDefault();
|
|
1507
|
-
searchBox.focus();
|
|
1508
|
-
} else if (event.key === 'f' && document.activeElement !== searchBox) {
|
|
1509
|
-
view = { k: 1, x: 0, y: 0 };
|
|
1510
|
-
applyView();
|
|
1511
|
-
}
|
|
1512
|
-
});
|
|
1513
|
-
|
|
1514
|
-
applyView();
|
|
1515
|
-
refresh();
|
|
1516
|
-
if (pendingNode) {
|
|
1517
|
-
var linked = nodes.filter(function (node) {
|
|
1518
|
-
return node.key === pendingNode || node.id === pendingNode || node.slug === pendingNode;
|
|
1519
|
-
})[0];
|
|
1520
|
-
if (linked) select(linked.key);
|
|
1521
|
-
}
|
|
1522
|
-
})();
|
|
1523
|
-
`
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
/* ---------------------------------------------------------------- render */
|
|
1527
|
-
|
|
1528
|
-
function escapeForScript(json) {
|
|
1529
|
-
// `</script>` inside a card title would end the block; < is escaped everywhere
|
|
1530
|
-
// because it only ever appears inside a JSON string.
|
|
1531
|
-
return json
|
|
1532
|
-
.replace(/</g, '\\u003c')
|
|
1533
|
-
.replace(/\u2028/g, '\\u2028')
|
|
1534
|
-
.replace(/\u2029/g, '\\u2029')
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
function stamp() {
|
|
1538
|
-
const now = new Date()
|
|
1539
|
-
const pad = (n) => String(n).padStart(2, '0')
|
|
1540
|
-
return (
|
|
1541
|
-
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
|
1542
|
-
`T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
|
1543
|
-
)
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
/**
|
|
1547
|
-
* Render the whole vault as one offline HTML page.
|
|
1548
|
-
*
|
|
1549
|
-
* @param {{ cards?: object[], entities?: object[], projects?: object[], generatedAt?: string }} input
|
|
1550
|
-
* @returns {string} a complete, self-contained HTML document
|
|
1551
|
-
*/
|
|
1552
|
-
export function renderGraphHtml({ cards = [], entities = [], projects = [], generatedAt = '' } = {}) {
|
|
1553
|
-
const payload = toPayload({
|
|
1554
|
-
cards,
|
|
1555
|
-
entities,
|
|
1556
|
-
projects,
|
|
1557
|
-
generatedAt: generatedAt || stamp(),
|
|
1558
|
-
})
|
|
1559
|
-
const dataJson = escapeForScript(JSON.stringify(payload))
|
|
1560
|
-
const title = `记忆图 · ${payload.counts.cards} cards / ${payload.counts.entities} entities`
|
|
1561
|
-
|
|
1562
|
-
return `<!doctype html>
|
|
1563
|
-
<html lang="zh-CN">
|
|
1564
|
-
<head>
|
|
1565
|
-
<meta charset="utf-8">
|
|
1566
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1567
|
-
<meta name="color-scheme" content="dark light">
|
|
1568
|
-
<title>${title.replace(/</g, '<')}</title>
|
|
1569
|
-
<style>${CSS}</style>
|
|
1570
|
-
</head>
|
|
1571
|
-
<body>
|
|
1572
|
-
${BODY}
|
|
1573
|
-
<script>${clientScript(dataJson)}</script>
|
|
1574
|
-
</body>
|
|
1575
|
-
</html>
|
|
1576
|
-
`
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
/** Content hash of the rendered page, handy for "did anything change". */
|
|
1580
|
-
export function graphDigest(html) {
|
|
1581
|
-
return crypto.createHash('sha256').update(html, 'utf8').digest('hex').slice(0, 12)
|
|
1582
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Graph view: the whole vault as one self-contained HTML page.
|
|
3
|
+
*
|
|
4
|
+
* The output is deliberately a single file with inline CSS and inline script —
|
|
5
|
+
* no network, no CDN, no font download, no build step. Double-click it and it
|
|
6
|
+
* works; zip the vault and it still works. Nothing is fetched, so the page says
|
|
7
|
+
* the same thing offline ten years from now.
|
|
8
|
+
*
|
|
9
|
+
* Layout is computed here, in Node, from the vault content: the page opens
|
|
10
|
+
* already settled instead of animating from a random pile, and two runs over
|
|
11
|
+
* the same vault produce the same picture (byte for byte, generation stamp
|
|
12
|
+
* aside). The browser side only draws, filters and hit-tests.
|
|
13
|
+
*/
|
|
14
|
+
import crypto from 'node:crypto'
|
|
15
|
+
|
|
16
|
+
/** Five card kinds, one hue each. Entity nodes get their own near-white. */
|
|
17
|
+
export const KIND_COLORS = {
|
|
18
|
+
decision: '#6ee7b7',
|
|
19
|
+
fact: '#7dd3fc',
|
|
20
|
+
open_question: '#fbbf24',
|
|
21
|
+
preference: '#c4b5fd',
|
|
22
|
+
note: '#94a3b8',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const KIND_LABELS = {
|
|
26
|
+
decision: '决策 decision',
|
|
27
|
+
fact: '事实 fact',
|
|
28
|
+
open_question: '待解 open_question',
|
|
29
|
+
preference: '偏好 preference',
|
|
30
|
+
note: '记录 note',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The legend sits in a status bar below the canvas, so the only thing the
|
|
34
|
+
// layout has to keep clear at the bottom is the frame itself.
|
|
35
|
+
const VIEW = { width: 1400, height: 860, pad: 70, reserveBottom: 16 }
|
|
36
|
+
|
|
37
|
+
const LABEL_FONT = { card: 10.5, entity: 11.5 }
|
|
38
|
+
const LABEL_MAX = { card: 126, entity: 138 }
|
|
39
|
+
|
|
40
|
+
/* ---------------------------------------------------------------- helpers */
|
|
41
|
+
|
|
42
|
+
function round1(n) {
|
|
43
|
+
return Math.round(n * 10) / 10
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clampText(text, max) {
|
|
47
|
+
const flat = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
48
|
+
if (flat.length <= max) return flat
|
|
49
|
+
return `${flat.slice(0, max - 1).trimEnd()}…`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rough advance width, no font metrics in Node: CJK is full-width, latin about
|
|
54
|
+
* 0.6em (system-ui at 10.5px averages a bit wider than the classic 0.5em).
|
|
55
|
+
* Baked label boxes only need to be close, not exact — they are used to avoid
|
|
56
|
+
* collisions, and the browser strokes every label with the page colour so a
|
|
57
|
+
* couple of pixels of drift stay readable.
|
|
58
|
+
*/
|
|
59
|
+
function textWidth(text, size) {
|
|
60
|
+
let width = 0
|
|
61
|
+
for (const char of String(text)) {
|
|
62
|
+
const code = char.codePointAt(0)
|
|
63
|
+
if (code >= 0x2e80 && code <= 0x9fff) width += size
|
|
64
|
+
else if (code >= 0xff00 && code <= 0xffef) width += size
|
|
65
|
+
else if (code >= 0x3000 && code <= 0x303f) width += size
|
|
66
|
+
else if (char === ' ') width += size * 0.32
|
|
67
|
+
else width += size * 0.6
|
|
68
|
+
}
|
|
69
|
+
return width
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function truncateToWidth(text, size, maxWidth) {
|
|
73
|
+
if (textWidth(text, size) <= maxWidth) return text
|
|
74
|
+
let out = ''
|
|
75
|
+
for (const char of String(text)) {
|
|
76
|
+
if (textWidth(`${out}${char}…`, size) > maxWidth) break
|
|
77
|
+
out += char
|
|
78
|
+
}
|
|
79
|
+
return `${out.trimEnd()}…`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hashString(text) {
|
|
83
|
+
let h = 2166136261
|
|
84
|
+
for (let i = 0; i < text.length; i++) {
|
|
85
|
+
h ^= text.charCodeAt(i)
|
|
86
|
+
h = Math.imul(h, 16777619)
|
|
87
|
+
}
|
|
88
|
+
return h >>> 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function makeRng(seed) {
|
|
92
|
+
let state = (seed >>> 0) || 1
|
|
93
|
+
return () => {
|
|
94
|
+
state = (state + 0x6d2b79f5) >>> 0
|
|
95
|
+
let t = state
|
|
96
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
97
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
98
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cardRadius(degree) {
|
|
103
|
+
return round1(5.4 + Math.min(4.2, Math.sqrt(degree) * 1.7))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function entityRadius(degree) {
|
|
107
|
+
return round1(8.5 + Math.min(10.5, Math.sqrt(degree) * 2.2))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ---------------------------------------------------------------- model */
|
|
111
|
+
|
|
112
|
+
function buildModel({ cards, entities, projects }) {
|
|
113
|
+
const nodes = []
|
|
114
|
+
const bySlug = new Map()
|
|
115
|
+
const byId = new Map()
|
|
116
|
+
|
|
117
|
+
for (const entity of entities) {
|
|
118
|
+
const node = {
|
|
119
|
+
key: `e:${entity.slug}`,
|
|
120
|
+
type: 'entity',
|
|
121
|
+
kind: 'entity',
|
|
122
|
+
label: String(entity.name ?? entity.slug),
|
|
123
|
+
slug: entity.slug,
|
|
124
|
+
degree: 0,
|
|
125
|
+
r: 0,
|
|
126
|
+
projects: [],
|
|
127
|
+
mentions: (entity.mentions ?? []).map((mention) => ({
|
|
128
|
+
d: String(mention.date ?? '').slice(0, 10),
|
|
129
|
+
note: clampText(mention.note, 120),
|
|
130
|
+
cardId: mention.cardId,
|
|
131
|
+
})),
|
|
132
|
+
etype: entity.type ?? 'concept',
|
|
133
|
+
aliases: entity.aliases ?? [],
|
|
134
|
+
firstSeen: entity.firstSeen ?? null,
|
|
135
|
+
lastSeen: entity.lastSeen ?? null,
|
|
136
|
+
}
|
|
137
|
+
bySlug.set(entity.slug, node)
|
|
138
|
+
nodes.push(node)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const card of cards) {
|
|
142
|
+
const node = {
|
|
143
|
+
key: `c:${card.id}`,
|
|
144
|
+
type: 'card',
|
|
145
|
+
kind: String(card.kind ?? 'note'),
|
|
146
|
+
label: String(card.title ?? '(untitled)'),
|
|
147
|
+
id: card.id,
|
|
148
|
+
project: card.project ?? 'shared',
|
|
149
|
+
status: card.status ?? 'active',
|
|
150
|
+
confidence: card.confidence ?? 'inferred',
|
|
151
|
+
created: String(card.created ?? '').slice(0, 10),
|
|
152
|
+
snippet: clampText(card.body, 160),
|
|
153
|
+
body: clampText(card.body, 900),
|
|
154
|
+
entities: (card.entities ?? []).map((slug) => ({
|
|
155
|
+
slug,
|
|
156
|
+
name: bySlug.get(slug)?.label ?? slug,
|
|
157
|
+
})),
|
|
158
|
+
supersedes: card.supersedes ?? [],
|
|
159
|
+
supersededBy: card.supersededBy ?? null,
|
|
160
|
+
tags: card.tags ?? [],
|
|
161
|
+
degree: 0,
|
|
162
|
+
r: 0,
|
|
163
|
+
projects: [card.project ?? 'shared'],
|
|
164
|
+
}
|
|
165
|
+
byId.set(card.id, node)
|
|
166
|
+
nodes.push(node)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const links = []
|
|
170
|
+
const seen = new Set()
|
|
171
|
+
for (const card of cards) {
|
|
172
|
+
const source = byId.get(card.id)
|
|
173
|
+
for (const slug of card.entities ?? []) {
|
|
174
|
+
const target = bySlug.get(slug)
|
|
175
|
+
if (!target) continue
|
|
176
|
+
const key = `${source.key}->${target.key}`
|
|
177
|
+
if (seen.has(key)) continue
|
|
178
|
+
seen.add(key)
|
|
179
|
+
links.push({ s: source.key, t: target.key, k: 'mention' })
|
|
180
|
+
source.degree += 1
|
|
181
|
+
target.degree += 1
|
|
182
|
+
if (!target.projects.includes(source.project)) target.projects.push(source.project)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const card of cards) {
|
|
187
|
+
const source = byId.get(card.id)
|
|
188
|
+
const old = card.supersededBy ? byId.get(card.supersededBy) : null
|
|
189
|
+
if (!source || !old) continue
|
|
190
|
+
const key = `${source.key}->${old.key}`
|
|
191
|
+
if (seen.has(key)) continue
|
|
192
|
+
seen.add(key)
|
|
193
|
+
links.push({ s: source.key, t: old.key, k: 'supersede' })
|
|
194
|
+
source.degree += 1
|
|
195
|
+
old.degree += 1
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const node of nodes) {
|
|
199
|
+
node.r = node.type === 'entity' ? entityRadius(node.degree) : cardRadius(node.degree)
|
|
200
|
+
node.haloScale = node.type === 'entity' ? 2.5 : 2.7
|
|
201
|
+
// A hub is an entity that only exists because several projects share it.
|
|
202
|
+
node.hub = node.type === 'entity' && node.projects.length >= 2
|
|
203
|
+
node.projects.sort()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const projectCounts = new Map()
|
|
207
|
+
for (const card of cards) {
|
|
208
|
+
const slug = card.project ?? 'shared'
|
|
209
|
+
projectCounts.set(slug, (projectCounts.get(slug) ?? 0) + 1)
|
|
210
|
+
}
|
|
211
|
+
const projectList = [...projectCounts.entries()]
|
|
212
|
+
.map(([slug, count]) => ({ slug, count, title: projects.find((p) => p.slug === slug)?.title ?? slug }))
|
|
213
|
+
.sort((a, b) => b.count - a.count || a.slug.localeCompare(b.slug))
|
|
214
|
+
|
|
215
|
+
return { nodes, links, projectList }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ---------------------------------------------------------------- layout */
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Fruchterman–Reingold relaxation with a gravity term: springs along links,
|
|
222
|
+
* inverse-distance repulsion between every pair, a stronger pull to the middle
|
|
223
|
+
* for entity nodes so the things that many cards share end up in the centre.
|
|
224
|
+
* Deterministic (phyllotaxis start, no wall clock, no Math.random).
|
|
225
|
+
*/
|
|
226
|
+
function layoutGraph(nodes, links) {
|
|
227
|
+
const { width, height } = VIEW
|
|
228
|
+
const n = nodes.length
|
|
229
|
+
const index = new Map(nodes.map((node, i) => [node.key, i]))
|
|
230
|
+
const xs = new Float64Array(n)
|
|
231
|
+
const ys = new Float64Array(n)
|
|
232
|
+
const dxs = new Float64Array(n)
|
|
233
|
+
const dys = new Float64Array(n)
|
|
234
|
+
const rng = makeRng(hashString(nodes.map((node) => node.key).join('|')))
|
|
235
|
+
|
|
236
|
+
// Golden-angle spiral: evenly spaced, no clusters to untangle first.
|
|
237
|
+
const spread = Math.min(width, height) * 0.34
|
|
238
|
+
for (let i = 0; i < n; i++) {
|
|
239
|
+
const angle = i * 2.399963229728653
|
|
240
|
+
const radius = spread * Math.sqrt((i + 0.5) / Math.max(1, n))
|
|
241
|
+
xs[i] = width / 2 + Math.cos(angle) * radius
|
|
242
|
+
ys[i] = height / 2 + Math.sin(angle) * radius * 0.82
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const edges = []
|
|
246
|
+
for (const link of links) {
|
|
247
|
+
const a = index.get(link.s)
|
|
248
|
+
const b = index.get(link.t)
|
|
249
|
+
if (a === undefined || b === undefined) continue
|
|
250
|
+
edges.push([a, b, link.k === 'supersede' ? 1.35 : 1])
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Fruchterman–Reingold wants an ideal edge length that grows as the frame gets
|
|
254
|
+
// emptier, which is right for big graphs and absurd for small ones: two cells
|
|
255
|
+
// would be flung to opposite corners. Hence the ceiling.
|
|
256
|
+
const ideal = Math.min(132, 1.05 * Math.sqrt((width * height) / Math.max(8, n)))
|
|
257
|
+
let temperature = ideal * 0.9
|
|
258
|
+
const iterations = 460
|
|
259
|
+
|
|
260
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
261
|
+
dxs.fill(0)
|
|
262
|
+
dys.fill(0)
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < n; i++) {
|
|
265
|
+
for (let j = i + 1; j < n; j++) {
|
|
266
|
+
let dx = xs[i] - xs[j]
|
|
267
|
+
let dy = ys[i] - ys[j]
|
|
268
|
+
let d2 = dx * dx + dy * dy
|
|
269
|
+
if (d2 < 0.02) {
|
|
270
|
+
dx = (rng() - 0.5) * 0.6
|
|
271
|
+
dy = (rng() - 0.5) * 0.6
|
|
272
|
+
d2 = dx * dx + dy * dy + 0.02
|
|
273
|
+
}
|
|
274
|
+
const d = Math.sqrt(d2)
|
|
275
|
+
const force = (ideal * ideal) / d
|
|
276
|
+
const ux = dx / d
|
|
277
|
+
const uy = dy / d
|
|
278
|
+
dxs[i] += ux * force
|
|
279
|
+
dys[i] += uy * force
|
|
280
|
+
dxs[j] -= ux * force
|
|
281
|
+
dys[j] -= uy * force
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const [a, b, weight] of edges) {
|
|
286
|
+
const dx = xs[a] - xs[b]
|
|
287
|
+
const dy = ys[a] - ys[b]
|
|
288
|
+
const d = Math.sqrt(dx * dx + dy * dy) || 0.01
|
|
289
|
+
const force = ((d * d) / ideal) * 0.82 * weight
|
|
290
|
+
const ux = dx / d
|
|
291
|
+
const uy = dy / d
|
|
292
|
+
dxs[a] -= ux * force
|
|
293
|
+
dys[a] -= uy * force
|
|
294
|
+
dxs[b] += ux * force
|
|
295
|
+
dys[b] += uy * force
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (let i = 0; i < n; i++) {
|
|
299
|
+
// Entities are the hubs: hold them near the middle so cards ring them.
|
|
300
|
+
const gravity = nodes[i].type === 'entity' ? 0.19 : 0.062
|
|
301
|
+
dxs[i] += (width / 2 - xs[i]) * gravity
|
|
302
|
+
dys[i] += (height / 2 - ys[i]) * gravity
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (let i = 0; i < n; i++) {
|
|
306
|
+
const d = Math.sqrt(dxs[i] * dxs[i] + dys[i] * dys[i]) || 1
|
|
307
|
+
const step = Math.min(d, temperature)
|
|
308
|
+
xs[i] += (dxs[i] / d) * step
|
|
309
|
+
ys[i] += (dys[i] / d) * step
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Hard separation: two nodes never share a spot, whatever the forces say.
|
|
313
|
+
for (let i = 0; i < n; i++) {
|
|
314
|
+
for (let j = i + 1; j < n; j++) {
|
|
315
|
+
const min = nodes[i].r + nodes[j].r + 15
|
|
316
|
+
let dx = xs[j] - xs[i]
|
|
317
|
+
let dy = ys[j] - ys[i]
|
|
318
|
+
let d = Math.sqrt(dx * dx + dy * dy)
|
|
319
|
+
if (d >= min) continue
|
|
320
|
+
if (d < 0.01) {
|
|
321
|
+
dx = rng() - 0.5
|
|
322
|
+
dy = rng() - 0.5
|
|
323
|
+
d = Math.sqrt(dx * dx + dy * dy) || 0.01
|
|
324
|
+
}
|
|
325
|
+
const push = (min - d) / 2
|
|
326
|
+
xs[i] -= (dx / d) * push
|
|
327
|
+
ys[i] -= (dy / d) * push
|
|
328
|
+
xs[j] += (dx / d) * push
|
|
329
|
+
ys[j] += (dy / d) * push
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
temperature = Math.max(0.6, temperature * 0.986)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Fit into the padded frame. The two axes are allowed to stretch by different
|
|
337
|
+
// amounts (capped): a round cloud would leave the wide frame half empty, and
|
|
338
|
+
// stretching positions — never the circles — keeps every node perfectly round.
|
|
339
|
+
let minX = Infinity
|
|
340
|
+
let minY = Infinity
|
|
341
|
+
let maxX = -Infinity
|
|
342
|
+
let maxY = -Infinity
|
|
343
|
+
for (let i = 0; i < n; i++) {
|
|
344
|
+
minX = Math.min(minX, xs[i] - nodes[i].r)
|
|
345
|
+
maxX = Math.max(maxX, xs[i] + nodes[i].r)
|
|
346
|
+
minY = Math.min(minY, ys[i] - nodes[i].r)
|
|
347
|
+
maxY = Math.max(maxY, ys[i] + nodes[i].r)
|
|
348
|
+
}
|
|
349
|
+
const boxW = VIEW.width - VIEW.pad * 2
|
|
350
|
+
// The legend and the hint live in a strip along the bottom edge; nodes are
|
|
351
|
+
// kept out of it so a cluster can never end up behind the legend box.
|
|
352
|
+
const boxH = VIEW.height - VIEW.pad - VIEW.reserveBottom
|
|
353
|
+
const spanW = Math.max(1, maxX - minX)
|
|
354
|
+
const spanH = Math.max(1, maxY - minY)
|
|
355
|
+
const scaleX = Math.min(boxW / spanW, 1.45)
|
|
356
|
+
const scaleY = Math.min(boxH / spanH, 1.35)
|
|
357
|
+
const radiusScale = Math.min(1.3, Math.max(0.9, Math.sqrt(scaleX * scaleY)))
|
|
358
|
+
const offsetX = VIEW.pad + (boxW - spanW * scaleX) / 2
|
|
359
|
+
const offsetY = VIEW.pad + (boxH - spanH * scaleY) / 2
|
|
360
|
+
|
|
361
|
+
for (let i = 0; i < n; i++) {
|
|
362
|
+
nodes[i].x = round1(offsetX + (xs[i] - minX) * scaleX)
|
|
363
|
+
nodes[i].y = round1(offsetY + (ys[i] - minY) * scaleY)
|
|
364
|
+
nodes[i].r = round1(nodes[i].r * radiusScale)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The radii that were just applied are not the ones the span above was
|
|
368
|
+
// measured with, so the outermost cells can poke a few pixels past the
|
|
369
|
+
// padding. Re-measure with the real circles and pull the picture back in —
|
|
370
|
+
// positions shrink, circles never do.
|
|
371
|
+
const frame = {
|
|
372
|
+
x0: VIEW.pad,
|
|
373
|
+
x1: VIEW.width - VIEW.pad,
|
|
374
|
+
y0: VIEW.pad,
|
|
375
|
+
y1: VIEW.height - VIEW.reserveBottom,
|
|
376
|
+
}
|
|
377
|
+
let bx0 = Infinity
|
|
378
|
+
let bx1 = -Infinity
|
|
379
|
+
let by0 = Infinity
|
|
380
|
+
let by1 = -Infinity
|
|
381
|
+
for (let i = 0; i < n; i++) {
|
|
382
|
+
bx0 = Math.min(bx0, nodes[i].x - nodes[i].r)
|
|
383
|
+
bx1 = Math.max(bx1, nodes[i].x + nodes[i].r)
|
|
384
|
+
by0 = Math.min(by0, nodes[i].y - nodes[i].r)
|
|
385
|
+
by1 = Math.max(by1, nodes[i].y + nodes[i].r)
|
|
386
|
+
}
|
|
387
|
+
if (bx0 < frame.x0 || bx1 > frame.x1 || by0 < frame.y0 || by1 > frame.y1) {
|
|
388
|
+
const shrink = Math.min(
|
|
389
|
+
(frame.x1 - frame.x0) / Math.max(1, bx1 - bx0),
|
|
390
|
+
(frame.y1 - frame.y0) / Math.max(1, by1 - by0),
|
|
391
|
+
1,
|
|
392
|
+
)
|
|
393
|
+
const cx = (bx0 + bx1) / 2
|
|
394
|
+
const cy = (by0 + by1) / 2
|
|
395
|
+
const tx = (frame.x0 + frame.x1) / 2
|
|
396
|
+
const ty = (frame.y0 + frame.y1) / 2
|
|
397
|
+
for (let i = 0; i < n; i++) {
|
|
398
|
+
nodes[i].x = round1(tx + (nodes[i].x - cx) * shrink)
|
|
399
|
+
nodes[i].y = round1(ty + (nodes[i].y - cy) * shrink)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* ---------------------------------------------------------------- labels */
|
|
405
|
+
|
|
406
|
+
function overlaps(a, b) {
|
|
407
|
+
return !(a.x1 <= b.x0 || b.x1 <= a.x0 || a.y1 <= b.y0 || b.y1 <= a.y0)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Greedy label placement: ten preferred offsets around the node, biggest nodes
|
|
412
|
+
* first. A label that fits nowhere is kept but hidden by default (hover,
|
|
413
|
+
* selection and search reveal it), which is what keeps the picture from turning
|
|
414
|
+
* into a plate of confetti once a vault has fifty entities.
|
|
415
|
+
*/
|
|
416
|
+
function placeLabels(nodes) {
|
|
417
|
+
const bodies = nodes.map((node) => ({
|
|
418
|
+
node,
|
|
419
|
+
box: {
|
|
420
|
+
x0: node.x - node.r - 2,
|
|
421
|
+
x1: node.x + node.r + 2,
|
|
422
|
+
y0: node.y - node.r - 2,
|
|
423
|
+
y1: node.y + node.r + 2,
|
|
424
|
+
},
|
|
425
|
+
}))
|
|
426
|
+
const taken = []
|
|
427
|
+
|
|
428
|
+
const ordered = nodes
|
|
429
|
+
.slice()
|
|
430
|
+
.sort((a, b) => {
|
|
431
|
+
if (a.type !== b.type) return a.type === 'entity' ? -1 : 1
|
|
432
|
+
return b.degree - a.degree
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const floor = VIEW.height - VIEW.reserveBottom - 6
|
|
436
|
+
|
|
437
|
+
for (const node of ordered) {
|
|
438
|
+
const size = node.type === 'entity' ? LABEL_FONT.entity : LABEL_FONT.card
|
|
439
|
+
const text = truncateToWidth(node.label, size, node.type === 'entity' ? LABEL_MAX.entity : LABEL_MAX.card)
|
|
440
|
+
node.short = text
|
|
441
|
+
const width = textWidth(text, size) + 10
|
|
442
|
+
const candidates = [
|
|
443
|
+
{ dx: 0, dy: node.r + 10, anchor: 'middle' },
|
|
444
|
+
{ dx: 0, dy: -(node.r + 8), anchor: 'middle' },
|
|
445
|
+
{ dx: node.r + 7, dy: 1, anchor: 'start' },
|
|
446
|
+
{ dx: -(node.r + 7), dy: 1, anchor: 'end' },
|
|
447
|
+
{ dx: 0, dy: node.r + 24, anchor: 'middle' },
|
|
448
|
+
{ dx: 0, dy: -(node.r + 22), anchor: 'middle' },
|
|
449
|
+
{ dx: node.r + 7, dy: -(node.r * 0.6 + 5), anchor: 'start' },
|
|
450
|
+
{ dx: node.r + 7, dy: node.r * 0.6 + 6, anchor: 'start' },
|
|
451
|
+
{ dx: -(node.r + 7), dy: -(node.r * 0.6 + 5), anchor: 'end' },
|
|
452
|
+
{ dx: -(node.r + 7), dy: node.r * 0.6 + 6, anchor: 'end' },
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
let placed = null
|
|
456
|
+
for (const candidate of candidates) {
|
|
457
|
+
const x = node.x + candidate.dx
|
|
458
|
+
const y = node.y + candidate.dy
|
|
459
|
+
const left = candidate.anchor === 'start' ? x : candidate.anchor === 'end' ? x - width : x - width / 2
|
|
460
|
+
// y is the text baseline, so the box is lopsided: cap height above the
|
|
461
|
+
// baseline, descender below it. Inflated a little on top of that, because
|
|
462
|
+
// the width estimate can be off by a pixel or two per glyph run.
|
|
463
|
+
const box = {
|
|
464
|
+
x0: left - 1.5,
|
|
465
|
+
x1: left + width + 1.5,
|
|
466
|
+
y0: y - size * 0.84 - 1.5,
|
|
467
|
+
y1: y + size * 0.24 + 1.5,
|
|
468
|
+
}
|
|
469
|
+
// Never let a label run off the frame: the page clips, and half a word is
|
|
470
|
+
// worse than no word.
|
|
471
|
+
if (box.x0 < 10 || box.x1 > VIEW.width - 10 || box.y0 < 8 || box.y1 > floor) continue
|
|
472
|
+
if (taken.some((other) => overlaps(box, other))) continue
|
|
473
|
+
// Other nodes are hard obstacles; the node this label belongs to is not,
|
|
474
|
+
// otherwise every candidate would collide with its own cell body.
|
|
475
|
+
if (bodies.some((entry) => entry.node !== node && overlaps(box, entry.box))) continue
|
|
476
|
+
taken.push(box)
|
|
477
|
+
placed = { x: round1(x), y: round1(y), anchor: candidate.anchor, on: true }
|
|
478
|
+
break
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
node.labelPos = placed ?? { x: round1(node.x), y: round1(node.y + node.r + 11), anchor: 'middle', on: false }
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/* ---------------------------------------------------------------- payload */
|
|
486
|
+
|
|
487
|
+
function toPayload({ cards, entities, projects, generatedAt }) {
|
|
488
|
+
const model = buildModel({ cards, entities, projects })
|
|
489
|
+
layoutGraph(model.nodes, model.links)
|
|
490
|
+
placeLabels(model.nodes)
|
|
491
|
+
|
|
492
|
+
const nodes = model.nodes.map((node) => {
|
|
493
|
+
const base = {
|
|
494
|
+
key: node.key,
|
|
495
|
+
type: node.type,
|
|
496
|
+
kind: node.kind,
|
|
497
|
+
label: node.short ?? node.label,
|
|
498
|
+
full: node.label,
|
|
499
|
+
x: node.x,
|
|
500
|
+
y: node.y,
|
|
501
|
+
r: node.r,
|
|
502
|
+
degree: node.degree,
|
|
503
|
+
projects: node.projects,
|
|
504
|
+
labelAt: node.labelPos,
|
|
505
|
+
}
|
|
506
|
+
if (node.type === 'entity') {
|
|
507
|
+
return {
|
|
508
|
+
...base,
|
|
509
|
+
slug: node.slug,
|
|
510
|
+
etype: node.etype,
|
|
511
|
+
aliases: node.aliases,
|
|
512
|
+
firstSeen: node.firstSeen,
|
|
513
|
+
lastSeen: node.lastSeen,
|
|
514
|
+
hub: node.hub,
|
|
515
|
+
mentions: node.mentions.slice(-24).reverse(),
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
...base,
|
|
520
|
+
id: node.id,
|
|
521
|
+
project: node.project,
|
|
522
|
+
status: node.status,
|
|
523
|
+
confidence: node.confidence,
|
|
524
|
+
created: node.created,
|
|
525
|
+
snippet: node.snippet,
|
|
526
|
+
body: node.body,
|
|
527
|
+
entities: node.entities,
|
|
528
|
+
supersedes: node.supersedes,
|
|
529
|
+
supersededBy: node.supersededBy,
|
|
530
|
+
tags: node.tags,
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
const counts = {
|
|
535
|
+
cards: cards.length,
|
|
536
|
+
entities: entities.length,
|
|
537
|
+
projects: model.projectList.length,
|
|
538
|
+
active: cards.filter((card) => (card.status ?? 'active') === 'active').length,
|
|
539
|
+
superseded: cards.filter((card) => card.status === 'superseded').length,
|
|
540
|
+
retracted: cards.filter((card) => card.status === 'retracted').length,
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
generatedAt,
|
|
545
|
+
counts,
|
|
546
|
+
kinds: KIND_LABELS,
|
|
547
|
+
nodes,
|
|
548
|
+
links: model.links,
|
|
549
|
+
projects: model.projectList,
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/* ---------------------------------------------------------------- assets */
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Two palettes. `:root` is dark; a light system flips it before the script runs,
|
|
557
|
+
* and the header toggle pins either one with a `data-theme` attribute on <html>
|
|
558
|
+
* (auto means "no attribute", so the media query stays in charge).
|
|
559
|
+
*/
|
|
560
|
+
const TOKENS = {
|
|
561
|
+
dark: `
|
|
562
|
+
color-scheme: dark;
|
|
563
|
+
--bg-0: #070b0f;
|
|
564
|
+
--bg-1: #0d141b;
|
|
565
|
+
--bg-2: #16222e;
|
|
566
|
+
--fg: #e8eef5;
|
|
567
|
+
--fg-dim: #8fa2b6;
|
|
568
|
+
--line: rgba(148, 163, 184, 0.18);
|
|
569
|
+
--edge: rgba(148, 163, 184, 0.36);
|
|
570
|
+
--mint: #6ee7b7;
|
|
571
|
+
--entity: #e8eef5;
|
|
572
|
+
--panel: rgba(13, 20, 27, 0.9);
|
|
573
|
+
--chip: rgba(148, 163, 184, 0.12);
|
|
574
|
+
--glow-a: rgba(45, 212, 191, 0.11);
|
|
575
|
+
--glow-b: rgba(56, 189, 248, 0.08);
|
|
576
|
+
--shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
|
|
577
|
+
--k-decision: #6ee7b7;
|
|
578
|
+
--k-fact: #7dd3fc;
|
|
579
|
+
--k-open_question: #fbbf24;
|
|
580
|
+
--k-preference: #c4b5fd;
|
|
581
|
+
--k-note: #94a3b8;`,
|
|
582
|
+
light: `
|
|
583
|
+
color-scheme: light;
|
|
584
|
+
--bg-0: #eaeff5;
|
|
585
|
+
--bg-1: #f7fafc;
|
|
586
|
+
--bg-2: #ffffff;
|
|
587
|
+
--fg: #101820;
|
|
588
|
+
--fg-dim: #4d6076;
|
|
589
|
+
--line: rgba(16, 24, 32, 0.16);
|
|
590
|
+
--edge: rgba(16, 24, 32, 0.32);
|
|
591
|
+
--mint: #0b8f6b;
|
|
592
|
+
--entity: #16222e;
|
|
593
|
+
--panel: rgba(255, 255, 255, 0.94);
|
|
594
|
+
--chip: rgba(16, 24, 32, 0.055);
|
|
595
|
+
--glow-a: rgba(13, 148, 136, 0.10);
|
|
596
|
+
--glow-b: rgba(2, 132, 199, 0.07);
|
|
597
|
+
--shadow: 0 18px 44px rgba(16, 24, 32, 0.2);
|
|
598
|
+
--k-decision: #0b8f6b;
|
|
599
|
+
--k-fact: #0b74b8;
|
|
600
|
+
--k-open_question: #a45a06;
|
|
601
|
+
--k-preference: #6d28d9;
|
|
602
|
+
--k-note: #55677a;`,
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const CSS = `
|
|
606
|
+
:root {${TOKENS.dark}
|
|
607
|
+
}
|
|
608
|
+
@media (prefers-color-scheme: light) {
|
|
609
|
+
:root {${TOKENS.light}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
:root[data-theme="dark"] {${TOKENS.dark}
|
|
613
|
+
}
|
|
614
|
+
:root[data-theme="light"] {${TOKENS.light}
|
|
615
|
+
}
|
|
616
|
+
* { box-sizing: border-box; }
|
|
617
|
+
html, body { height: 100%; margin: 0; }
|
|
618
|
+
body {
|
|
619
|
+
background: var(--bg-0);
|
|
620
|
+
color: var(--fg);
|
|
621
|
+
font: 13px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, "Microsoft YaHei", "PingFang SC", sans-serif;
|
|
622
|
+
-webkit-font-smoothing: antialiased;
|
|
623
|
+
overflow: hidden;
|
|
624
|
+
}
|
|
625
|
+
#app { display: grid; grid-template-rows: auto 1fr auto; height: 100%; }
|
|
626
|
+
|
|
627
|
+
header {
|
|
628
|
+
display: flex; align-items: center; gap: 14px; flex-wrap: nowrap;
|
|
629
|
+
padding: 10px 16px 11px;
|
|
630
|
+
border-bottom: 1px solid var(--line);
|
|
631
|
+
background: linear-gradient(180deg, var(--bg-1), rgba(0, 0, 0, 0));
|
|
632
|
+
}
|
|
633
|
+
.brand { display: flex; align-items: baseline; gap: 9px; min-width: 0; flex: 0 0 auto; }
|
|
634
|
+
.brand h1 { font-size: 15px; font-weight: 650; margin: 0; letter-spacing: 0.01em; white-space: nowrap; }
|
|
635
|
+
.brand span.en { font-size: 11.5px; color: var(--fg-dim); letter-spacing: 0.06em; text-transform: uppercase; white-space: nowrap; }
|
|
636
|
+
.brand::before {
|
|
637
|
+
content: ""; width: 9px; height: 9px; border-radius: 50%;
|
|
638
|
+
background: var(--mint); box-shadow: 0 0 0 4px rgba(110, 231, 183, 0.16);
|
|
639
|
+
align-self: center; flex: none;
|
|
640
|
+
}
|
|
641
|
+
.counts {
|
|
642
|
+
font-size: 11.5px; color: var(--fg-dim); font-variant-numeric: tabular-nums;
|
|
643
|
+
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
644
|
+
}
|
|
645
|
+
.counts b { color: var(--fg); font-weight: 600; }
|
|
646
|
+
header .spacer { flex: 1 1 0; min-width: 8px; }
|
|
647
|
+
.controls { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
|
|
648
|
+
input[type="search"], select, button {
|
|
649
|
+
font: inherit; color: var(--fg); background: var(--chip);
|
|
650
|
+
border: 1px solid var(--line); border-radius: 8px; padding: 5px 9px; outline: none;
|
|
651
|
+
}
|
|
652
|
+
input[type="search"] { width: 210px; min-width: 96px; flex: 0 1 auto; }
|
|
653
|
+
input[type="search"]::placeholder { color: var(--fg-dim); }
|
|
654
|
+
input[type="search"]:focus, select:focus { border-color: var(--mint); box-shadow: 0 0 0 3px rgba(110, 231, 183, 0.16); }
|
|
655
|
+
select { padding-right: 6px; cursor: pointer; max-width: 190px; }
|
|
656
|
+
button { cursor: pointer; min-width: 30px; flex: 0 0 auto; }
|
|
657
|
+
button:hover { border-color: var(--mint); color: var(--mint); }
|
|
658
|
+
.zoom { display: flex; gap: 4px; flex: 0 0 auto; }
|
|
659
|
+
|
|
660
|
+
main { position: relative; display: grid; grid-template-columns: 1fr; min-height: 0; }
|
|
661
|
+
#stage {
|
|
662
|
+
position: relative; min-height: 0; overflow: hidden;
|
|
663
|
+
background:
|
|
664
|
+
radial-gradient(900px 560px at 50% 44%, var(--glow-a) 0%, rgba(0, 0, 0, 0) 62%),
|
|
665
|
+
radial-gradient(700px 520px at 16% 92%, var(--glow-b) 0%, rgba(0, 0, 0, 0) 58%),
|
|
666
|
+
var(--bg-0);
|
|
667
|
+
box-shadow: inset 0 0 200px 40px rgba(0, 0, 0, 0.35);
|
|
668
|
+
}
|
|
669
|
+
@media (prefers-color-scheme: light) {
|
|
670
|
+
#stage { box-shadow: inset 0 0 170px 30px rgba(148, 163, 184, 0.20); }
|
|
671
|
+
}
|
|
672
|
+
#canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; cursor: grab; }
|
|
673
|
+
#canvas.panning { cursor: grabbing; }
|
|
674
|
+
|
|
675
|
+
.edge-glow { fill: none; stroke: var(--edge); stroke-width: 3.6; stroke-opacity: 0.32; stroke-linecap: round; }
|
|
676
|
+
.edge { fill: none; stroke: var(--edge); stroke-width: 1.05; stroke-linecap: round; }
|
|
677
|
+
.edge-group.supersede .edge { stroke: var(--mint); stroke-opacity: 0.55; stroke-dasharray: 4 4; }
|
|
678
|
+
.edge-group.supersede .edge-glow { stroke: var(--mint); stroke-opacity: 0.16; }
|
|
679
|
+
.edge-group.lit .edge { stroke: var(--mint); stroke-width: 1.7; stroke-dasharray: 6 6; animation: flow 1.4s linear infinite; }
|
|
680
|
+
.edge-group.lit .edge-glow { stroke: var(--mint); stroke-opacity: 0.34; }
|
|
681
|
+
.edge-group.dim { opacity: 0.09; }
|
|
682
|
+
@keyframes flow { to { stroke-dashoffset: -24; } }
|
|
683
|
+
|
|
684
|
+
.node { cursor: pointer; }
|
|
685
|
+
.node .halo { opacity: 0.9; }
|
|
686
|
+
.node.hub .halo { opacity: 1; }
|
|
687
|
+
.node .core { fill: currentColor; stroke: var(--bg-0); stroke-width: 0.9; }
|
|
688
|
+
.node.entity .core { stroke: var(--bg-1); stroke-width: 1.2; }
|
|
689
|
+
.node .nucleus { fill: var(--bg-0); }
|
|
690
|
+
.node.hub .ring { fill: none; stroke: currentColor; stroke-opacity: 0.55; stroke-width: 1.1; stroke-dasharray: 3 3.4; animation: spin 9s linear infinite; }
|
|
691
|
+
@keyframes spin { to { stroke-dashoffset: -52; } }
|
|
692
|
+
.node.superseded .core { stroke: var(--fg); stroke-opacity: 0.6; stroke-width: 1.1; stroke-dasharray: 2.6 2.2; }
|
|
693
|
+
.node.retracted { opacity: 0.34; }
|
|
694
|
+
.node.retracted .core { stroke-dasharray: 1.6 1.8; }
|
|
695
|
+
.node.hit .core { stroke: var(--mint); stroke-width: 2; }
|
|
696
|
+
.node.hit .halo { opacity: 0.3; }
|
|
697
|
+
.node.sel .core { stroke: var(--mint); stroke-width: 2.4; }
|
|
698
|
+
.node.sel .halo { opacity: 0.38; }
|
|
699
|
+
.node:hover .core { stroke: var(--fg); stroke-width: 1.8; }
|
|
700
|
+
.node.dim { opacity: 0.12; }
|
|
701
|
+
.node.dim.retracted { opacity: 0.06; }
|
|
702
|
+
.node.dim .halo { opacity: 0.16; }
|
|
703
|
+
|
|
704
|
+
.k-decision { color: var(--k-decision); }
|
|
705
|
+
.k-fact { color: var(--k-fact); }
|
|
706
|
+
.k-open_question { color: var(--k-open_question); }
|
|
707
|
+
.k-preference { color: var(--k-preference); }
|
|
708
|
+
.k-note { color: var(--k-note); }
|
|
709
|
+
.k-entity { color: var(--entity); }
|
|
710
|
+
|
|
711
|
+
text.lbl {
|
|
712
|
+
font-family: inherit; font-size: 10.5px; font-weight: 500;
|
|
713
|
+
fill: var(--fg); fill-opacity: 0.82;
|
|
714
|
+
stroke: var(--bg-0); stroke-width: 3.2px; stroke-linejoin: round;
|
|
715
|
+
paint-order: stroke fill; pointer-events: none; user-select: none;
|
|
716
|
+
}
|
|
717
|
+
text.lbl.entity { font-size: 11.5px; font-weight: 650; fill-opacity: 0.96; }
|
|
718
|
+
text.lbl.off { display: none; }
|
|
719
|
+
text.lbl.dim { fill-opacity: 0.12; }
|
|
720
|
+
|
|
721
|
+
#empty {
|
|
722
|
+
position: absolute; inset: 0; display: flex; flex-direction: column;
|
|
723
|
+
align-items: center; justify-content: center; gap: 10px; padding: 40px;
|
|
724
|
+
text-align: center; color: var(--fg-dim);
|
|
725
|
+
}
|
|
726
|
+
#empty[hidden] { display: none; }
|
|
727
|
+
#empty h2 { margin: 0; font-size: 17px; color: var(--fg); font-weight: 600; }
|
|
728
|
+
#empty p { margin: 0; max-width: 540px; line-height: 1.85; }
|
|
729
|
+
#empty code { background: var(--chip); border-radius: 5px; padding: 1px 5px; font-size: 12px; }
|
|
730
|
+
|
|
731
|
+
#tooltip {
|
|
732
|
+
position: absolute; z-index: 6; max-width: 330px; padding: 10px 12px;
|
|
733
|
+
border-radius: 10px; border: 1px solid var(--line); background: var(--panel);
|
|
734
|
+
box-shadow: var(--shadow); backdrop-filter: blur(9px);
|
|
735
|
+
opacity: 0; transform: translateY(3px); transition: opacity 0.12s ease, transform 0.12s ease;
|
|
736
|
+
pointer-events: none;
|
|
737
|
+
}
|
|
738
|
+
#tooltip.on { opacity: 1; transform: none; }
|
|
739
|
+
#tooltip .tt-title { font-weight: 600; font-size: 12.5px; margin-bottom: 3px; }
|
|
740
|
+
#tooltip .tt-snip { color: var(--fg-dim); font-size: 11.5px; margin-bottom: 5px; }
|
|
741
|
+
#tooltip .tt-meta { color: var(--fg-dim); font-size: 10.5px; font-variant-numeric: tabular-nums; }
|
|
742
|
+
#tooltip .tt-kind { display: inline-block; margin-bottom: 5px; }
|
|
743
|
+
|
|
744
|
+
.badge {
|
|
745
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
746
|
+
font-size: 10.5px; padding: 1px 7px; border-radius: 999px;
|
|
747
|
+
background: var(--chip); color: var(--fg-dim);
|
|
748
|
+
}
|
|
749
|
+
.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
750
|
+
.badge.k-decision { color: var(--k-decision); }
|
|
751
|
+
.badge.k-fact { color: var(--k-fact); }
|
|
752
|
+
.badge.k-open_question { color: var(--k-open_question); }
|
|
753
|
+
.badge.k-preference { color: var(--k-preference); }
|
|
754
|
+
.badge.k-note { color: var(--k-note); }
|
|
755
|
+
.badge.k-entity { color: var(--entity); }
|
|
756
|
+
.badge.plain::before { display: none; }
|
|
757
|
+
.badge.warn { color: var(--k-open_question); }
|
|
758
|
+
.badge[hidden] { display: none; }
|
|
759
|
+
|
|
760
|
+
#panel {
|
|
761
|
+
position: absolute; top: 14px; right: 14px; width: 344px;
|
|
762
|
+
max-height: calc(100% - 28px); overflow: auto;
|
|
763
|
+
padding: 16px 18px 18px; border-radius: 14px;
|
|
764
|
+
border: 1px solid var(--line); background: var(--panel);
|
|
765
|
+
box-shadow: var(--shadow); backdrop-filter: blur(12px);
|
|
766
|
+
z-index: 5;
|
|
767
|
+
}
|
|
768
|
+
#panel[hidden] { display: none; }
|
|
769
|
+
#panel .eyebrow { font-size: 10.5px; letter-spacing: 0.09em; text-transform: uppercase; color: var(--fg-dim); }
|
|
770
|
+
#panel h2 { font-size: 14.5px; font-weight: 620; margin: 4px 0 10px; line-height: 1.45; }
|
|
771
|
+
#panel .row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
772
|
+
#panel dl { display: grid; grid-template-columns: 74px 1fr; gap: 3px 10px; margin: 0 0 12px; font-size: 11.5px; }
|
|
773
|
+
#panel dt { color: var(--fg-dim); }
|
|
774
|
+
#panel dd { margin: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
|
775
|
+
#panel .body { font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0 0 14px; color: var(--fg); }
|
|
776
|
+
#panel h3 { font-size: 10.5px; letter-spacing: 0.09em; text-transform: uppercase; color: var(--fg-dim); margin: 0 0 7px; font-weight: 600; }
|
|
777
|
+
#panel .chips { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 14px; }
|
|
778
|
+
#panel .chip {
|
|
779
|
+
font-size: 11px; padding: 2px 8px; border-radius: 999px; cursor: pointer;
|
|
780
|
+
border: 1px solid var(--line); background: var(--chip); color: var(--fg);
|
|
781
|
+
}
|
|
782
|
+
#panel .chip:hover { border-color: var(--mint); color: var(--mint); }
|
|
783
|
+
#panel .chip.static { cursor: default; }
|
|
784
|
+
#panel .chip.static:hover { border-color: var(--line); color: var(--fg); }
|
|
785
|
+
#panel ul.mentions { list-style: none; margin: 0 0 10px; padding: 0; display: grid; gap: 5px; }
|
|
786
|
+
#panel ul.mentions li {
|
|
787
|
+
display: grid; grid-template-columns: 74px 1fr; gap: 8px; font-size: 11.5px;
|
|
788
|
+
padding: 4px 6px; border-radius: 7px; cursor: pointer; color: var(--fg);
|
|
789
|
+
}
|
|
790
|
+
#panel ul.mentions li:hover { background: var(--chip); }
|
|
791
|
+
#panel ul.mentions span.d { color: var(--fg-dim); font-variant-numeric: tabular-nums; }
|
|
792
|
+
#panel .close {
|
|
793
|
+
position: absolute; top: 10px; right: 10px; width: 24px; min-width: 24px; height: 24px;
|
|
794
|
+
padding: 0; line-height: 1; font-size: 15px; border-radius: 7px;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/* The legend and the keyboard hint live in a status bar *below* the canvas, so
|
|
798
|
+
no amount of graph can ever end up behind them. */
|
|
799
|
+
footer {
|
|
800
|
+
display: flex; align-items: center; justify-content: space-between; gap: 10px 18px;
|
|
801
|
+
flex-wrap: wrap; padding: 7px 16px 8px;
|
|
802
|
+
border-top: 1px solid var(--line);
|
|
803
|
+
background: var(--bg-1);
|
|
804
|
+
font-size: 10.5px; color: var(--fg-dim);
|
|
805
|
+
}
|
|
806
|
+
#legend { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
|
807
|
+
#legend .item { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
|
808
|
+
#legend .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex: none; }
|
|
809
|
+
#legend .dot.hollow { background: none; border: 1px dashed currentColor; border-radius: 50%; }
|
|
810
|
+
#legend .dot.ringed { background: none; border: 1px solid currentColor; box-shadow: 0 0 0 2px rgba(110, 231, 183, 0.25); }
|
|
811
|
+
#legend .i-decision { color: var(--k-decision); }
|
|
812
|
+
#legend .i-fact { color: var(--k-fact); }
|
|
813
|
+
#legend .i-open_question { color: var(--k-open_question); }
|
|
814
|
+
#legend .i-preference { color: var(--k-preference); }
|
|
815
|
+
#legend .i-note { color: var(--k-note); }
|
|
816
|
+
#legend .i-entity { color: var(--entity); }
|
|
817
|
+
#legend .i-mint { color: var(--mint); }
|
|
818
|
+
|
|
819
|
+
#hint { opacity: 0.85; text-align: right; }
|
|
820
|
+
@media (max-width: 860px) {
|
|
821
|
+
#hint { display: none; }
|
|
822
|
+
#panel { width: auto; left: 14px; }
|
|
823
|
+
input[type="search"] { width: 130px; }
|
|
824
|
+
}
|
|
825
|
+
@media (prefers-reduced-motion: reduce) {
|
|
826
|
+
.edge.lit, .node.hub .ring { animation: none; }
|
|
827
|
+
}
|
|
828
|
+
`
|
|
829
|
+
|
|
830
|
+
const BODY = `
|
|
831
|
+
<div id="app">
|
|
832
|
+
<header>
|
|
833
|
+
<div class="brand"><h1>记忆图</h1><span class="en">memory graph</span></div>
|
|
834
|
+
<div class="counts" id="counts"></div>
|
|
835
|
+
<div class="spacer"></div>
|
|
836
|
+
<div class="controls">
|
|
837
|
+
<input id="search" type="search" placeholder="搜索标题 / 实体 / id" autocomplete="off" spellcheck="false">
|
|
838
|
+
<select id="project" title="按项目过滤"></select>
|
|
839
|
+
<div class="zoom">
|
|
840
|
+
<button id="theme" title="主题:跟随系统 / 深色 / 浅色">◐</button>
|
|
841
|
+
<button id="zoom-in" title="放大">+</button>
|
|
842
|
+
<button id="zoom-out" title="缩小">−</button>
|
|
843
|
+
<button id="zoom-fit" title="复位 (f)">fit</button>
|
|
844
|
+
</div>
|
|
845
|
+
</div>
|
|
846
|
+
</header>
|
|
847
|
+
<main>
|
|
848
|
+
<div id="stage">
|
|
849
|
+
<svg id="canvas" viewBox="0 0 1400 860" preserveAspectRatio="xMidYMid meet" role="img" aria-label="记忆图">
|
|
850
|
+
<defs id="defs"></defs>
|
|
851
|
+
<g id="viewport">
|
|
852
|
+
<g id="edges"></g>
|
|
853
|
+
<g id="nodes"></g>
|
|
854
|
+
<g id="labels"></g>
|
|
855
|
+
</g>
|
|
856
|
+
</svg>
|
|
857
|
+
<div id="tooltip"></div>
|
|
858
|
+
<aside id="panel" hidden></aside>
|
|
859
|
+
<div id="empty" hidden>
|
|
860
|
+
<h2>记忆库还是空的</h2>
|
|
861
|
+
<p>这张图是从 <code>cards/</code> 和 <code>entities/</code> 读出来的。等 agent 写下第一张记忆卡片——或跑一次蒸馏——再重新生成一次 <code>graph.html</code>,这里就会长出第一颗神经元。</p>
|
|
862
|
+
</div>
|
|
863
|
+
<noscript><p style="padding:24px">这个页面需要 JavaScript 才能画图。</p></noscript>
|
|
864
|
+
</div>
|
|
865
|
+
</main>
|
|
866
|
+
<footer>
|
|
867
|
+
<div id="legend"></div>
|
|
868
|
+
<div id="hint">滚轮缩放 · 拖拽平移 · 点击节点看详情 · Esc 清除 · / 搜索</div>
|
|
869
|
+
</footer>
|
|
870
|
+
</div>
|
|
871
|
+
`
|
|
872
|
+
|
|
873
|
+
/* ---------------------------------------------------------------- browser */
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Everything below runs in the browser. Written with `var` and string
|
|
877
|
+
* concatenation on purpose: it is inlined into a template literal here, so it
|
|
878
|
+
* must not contain backticks or `${` of its own.
|
|
879
|
+
*/
|
|
880
|
+
function clientScript(dataJson) {
|
|
881
|
+
return `
|
|
882
|
+
(function () {
|
|
883
|
+
'use strict'
|
|
884
|
+
|
|
885
|
+
var DATA = ${dataJson};
|
|
886
|
+
var canvas = document.getElementById('canvas');
|
|
887
|
+
var NS = canvas.namespaceURI;
|
|
888
|
+
var viewport = document.getElementById('viewport');
|
|
889
|
+
var edgeLayer = document.getElementById('edges');
|
|
890
|
+
var nodeLayer = document.getElementById('nodes');
|
|
891
|
+
var labelLayer = document.getElementById('labels');
|
|
892
|
+
var stage = document.getElementById('stage');
|
|
893
|
+
var tooltip = document.getElementById('tooltip');
|
|
894
|
+
var panel = document.getElementById('panel');
|
|
895
|
+
var emptyBox = document.getElementById('empty');
|
|
896
|
+
var legendBox = document.getElementById('legend');
|
|
897
|
+
var countsBox = document.getElementById('counts');
|
|
898
|
+
var searchBox = document.getElementById('search');
|
|
899
|
+
var projectBox = document.getElementById('project');
|
|
900
|
+
|
|
901
|
+
var nodes = DATA.nodes;
|
|
902
|
+
var byKey = {};
|
|
903
|
+
var neighbors = {};
|
|
904
|
+
var selected = null;
|
|
905
|
+
var view = { k: 1, x: 0, y: 0 };
|
|
906
|
+
var GRADIENT_KINDS = ['decision', 'fact', 'open_question', 'preference', 'note', 'entity'];
|
|
907
|
+
|
|
908
|
+
/* ------------------------------------------------------------ theme */
|
|
909
|
+
|
|
910
|
+
var THEME_KEY = 'dsh-memory-graph-theme';
|
|
911
|
+
var themeButton = document.getElementById('theme');
|
|
912
|
+
var gradients = {};
|
|
913
|
+
|
|
914
|
+
function storedTheme() {
|
|
915
|
+
try { return localStorage.getItem(THEME_KEY) || 'auto'; } catch (error) { return 'auto' }
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function gradientStops() {
|
|
919
|
+
var styles = getComputedStyle(document.documentElement);
|
|
920
|
+
GRADIENT_KINDS.forEach(function (kind) {
|
|
921
|
+
var color = styles.getPropertyValue('--k-' + kind).trim() || '#94a3b8';
|
|
922
|
+
var stops = gradients[kind];
|
|
923
|
+
stops[0].setAttribute('stop-color', color);
|
|
924
|
+
stops[1].setAttribute('stop-color', color);
|
|
925
|
+
stops[2].setAttribute('stop-color', color);
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function applyTheme(mode) {
|
|
930
|
+
if (mode === 'auto') document.documentElement.removeAttribute('data-theme');
|
|
931
|
+
else document.documentElement.setAttribute('data-theme', mode);
|
|
932
|
+
themeButton.textContent = mode === 'dark' ? '☾' : mode === 'light' ? '☀' : '◐';
|
|
933
|
+
themeButton.title = '主题:' + (mode === 'auto' ? '跟随系统' : mode === 'dark' ? '深色' : '浅色');
|
|
934
|
+
gradientStops();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
themeButton.addEventListener('click', function () {
|
|
938
|
+
var next = storedTheme() === 'auto' ? 'dark' : storedTheme() === 'dark' ? 'light' : 'auto';
|
|
939
|
+
try { localStorage.setItem(THEME_KEY, next); } catch (error) { /* private mode */ }
|
|
940
|
+
applyTheme(next);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
if (window.matchMedia) {
|
|
944
|
+
var media = window.matchMedia('(prefers-color-scheme: light)');
|
|
945
|
+
var onChange = function () { if (storedTheme() === 'auto') gradientStops(); };
|
|
946
|
+
if (media.addEventListener) media.addEventListener('change', onChange);
|
|
947
|
+
else if (media.addListener) media.addListener(onChange);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
nodes.forEach(function (node) {
|
|
952
|
+
byKey[node.key] = node;
|
|
953
|
+
neighbors[node.key] = {};
|
|
954
|
+
});
|
|
955
|
+
DATA.links.forEach(function (link) {
|
|
956
|
+
if (!byKey[link.s] || !byKey[link.t]) return;
|
|
957
|
+
neighbors[link.s][link.t] = true;
|
|
958
|
+
neighbors[link.t][link.s] = true;
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
function el(name, attrs) {
|
|
962
|
+
var node = document.createElementNS(NS, name);
|
|
963
|
+
if (attrs) { for (var key in attrs) node.setAttribute(key, attrs[key]); }
|
|
964
|
+
return node;
|
|
965
|
+
}
|
|
966
|
+
function h(tag, cls, text) {
|
|
967
|
+
var node = document.createElement(tag);
|
|
968
|
+
if (cls) node.className = cls;
|
|
969
|
+
if (text !== undefined && text !== null) node.textContent = text;
|
|
970
|
+
return node;
|
|
971
|
+
}
|
|
972
|
+
function fmt(n) { return Math.round(n * 10) / 10; }
|
|
973
|
+
|
|
974
|
+
// One soft radial glow per kind: the cell body reads as light, not as a dot.
|
|
975
|
+
var defs = document.getElementById('defs');
|
|
976
|
+
GRADIENT_KINDS.forEach(function (kind) {
|
|
977
|
+
var gradient = el('radialGradient', { id: 'glow-' + kind });
|
|
978
|
+
var stops = [];
|
|
979
|
+
[[0, 0.42], [0.4, 0.13], [1, 0]].forEach(function (spec) {
|
|
980
|
+
var stop = el('stop', { offset: String(spec[0]), 'stop-opacity': String(spec[1]) });
|
|
981
|
+
gradient.appendChild(stop);
|
|
982
|
+
stops.push(stop);
|
|
983
|
+
});
|
|
984
|
+
defs.appendChild(gradient);
|
|
985
|
+
gradients[kind] = stops;
|
|
986
|
+
});
|
|
987
|
+
// The address bar can pin a theme and open one node: #dark, #light, and
|
|
988
|
+
// #dark&<card id or entity slug>. Screenshots use it, and so does anything
|
|
989
|
+
// that wants to link straight to one cell.
|
|
990
|
+
var initialTheme = storedTheme();
|
|
991
|
+
var pendingNode = '';
|
|
992
|
+
var fragment = String(location.hash || '').replace(/^#/, '');
|
|
993
|
+
try {
|
|
994
|
+
fragment = decodeURIComponent(fragment);
|
|
995
|
+
} catch (error) {
|
|
996
|
+
// A stray percent sign is not worth a blank page.
|
|
997
|
+
}
|
|
998
|
+
fragment.split('&').forEach(function (part) {
|
|
999
|
+
if (part === 'dark' || part === 'light') initialTheme = part;
|
|
1000
|
+
else if (part) pendingNode = part;
|
|
1001
|
+
});
|
|
1002
|
+
applyTheme(initialTheme);
|
|
1003
|
+
|
|
1004
|
+
/* ------------------------------------------------------------ header */
|
|
1005
|
+
|
|
1006
|
+
var c = DATA.counts;
|
|
1007
|
+
countsBox.textContent = '';
|
|
1008
|
+
[['卡片', c.cards], ['实体', c.entities], ['项目', c.projects]].forEach(function (pair, i) {
|
|
1009
|
+
if (i > 0) countsBox.appendChild(document.createTextNode(' · '));
|
|
1010
|
+
var b = h('b', null, String(pair[1]));
|
|
1011
|
+
countsBox.appendChild(document.createTextNode(pair[0] + ' '));
|
|
1012
|
+
countsBox.appendChild(b);
|
|
1013
|
+
});
|
|
1014
|
+
if (c.superseded) countsBox.appendChild(document.createTextNode(' · 已替换 ' + c.superseded));
|
|
1015
|
+
if (c.retracted) countsBox.appendChild(document.createTextNode(' · 已撤回 ' + c.retracted));
|
|
1016
|
+
if (DATA.generatedAt) {
|
|
1017
|
+
countsBox.appendChild(document.createTextNode(' · 生成于 ' + String(DATA.generatedAt).replace('T', ' ').slice(0, 16)));
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/* ------------------------------------------------------------ legend */
|
|
1021
|
+
|
|
1022
|
+
var LEGEND = [
|
|
1023
|
+
['decision', '决策'], ['fact', '事实'], ['open_question', '待解'],
|
|
1024
|
+
['preference', '偏好'], ['note', '记录']
|
|
1025
|
+
];
|
|
1026
|
+
LEGEND.forEach(function (pair) {
|
|
1027
|
+
var item = h('div', 'item');
|
|
1028
|
+
item.appendChild(h('span', 'dot i-' + pair[0]));
|
|
1029
|
+
item.appendChild(h('span', null, pair[1]));
|
|
1030
|
+
legendBox.appendChild(item);
|
|
1031
|
+
});
|
|
1032
|
+
var ent = h('div', 'item');
|
|
1033
|
+
ent.appendChild(h('span', 'dot i-entity'));
|
|
1034
|
+
ent.appendChild(h('span', null, '实体(越大连得越多)'));
|
|
1035
|
+
legendBox.appendChild(ent);
|
|
1036
|
+
var hub = h('div', 'item');
|
|
1037
|
+
hub.appendChild(h('span', 'dot hollow i-mint'));
|
|
1038
|
+
hub.appendChild(h('span', null, '跨项目枢纽'));
|
|
1039
|
+
legendBox.appendChild(hub);
|
|
1040
|
+
var sup = h('div', 'item');
|
|
1041
|
+
sup.appendChild(h('span', 'ringed i-mint'));
|
|
1042
|
+
sup.appendChild(h('span', null, '虚线 = 已被替换'));
|
|
1043
|
+
legendBox.appendChild(sup);
|
|
1044
|
+
|
|
1045
|
+
if (nodes.length === 0) {
|
|
1046
|
+
emptyBox.hidden = false;
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/* ------------------------------------------------------------ edges */
|
|
1051
|
+
|
|
1052
|
+
function hashNumber(text) {
|
|
1053
|
+
var sum = 0;
|
|
1054
|
+
for (var i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 100003;
|
|
1055
|
+
return sum;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
var edgeEls = [];
|
|
1059
|
+
DATA.links.forEach(function (link) {
|
|
1060
|
+
var a = byKey[link.s];
|
|
1061
|
+
var b = byKey[link.t];
|
|
1062
|
+
if (!a || !b) return;
|
|
1063
|
+
var dx = b.x - a.x;
|
|
1064
|
+
var dy = b.y - a.y;
|
|
1065
|
+
var len = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
1066
|
+
var ux = dx / len;
|
|
1067
|
+
var uy = dy / len;
|
|
1068
|
+
var bend = Math.min(42, len * 0.16) * (hashNumber(a.key + b.key) % 2 ? 1 : -1);
|
|
1069
|
+
// Start and end just outside the node, so lines do not pierce the halo.
|
|
1070
|
+
var ax = a.x + ux * (a.r + 1.5);
|
|
1071
|
+
var ay = a.y + uy * (a.r + 1.5);
|
|
1072
|
+
var bx = b.x - ux * (b.r + 1.5);
|
|
1073
|
+
var by = b.y - uy * (b.r + 1.5);
|
|
1074
|
+
var mx = (ax + bx) / 2 - uy * bend;
|
|
1075
|
+
var my = (ay + by) / 2 + ux * bend;
|
|
1076
|
+
var d = 'M' + fmt(ax) + ' ' + fmt(ay) + 'Q' + fmt(mx) + ' ' + fmt(my) + ' ' + fmt(bx) + ' ' + fmt(by);
|
|
1077
|
+
var group = el('g', { 'class': 'edge-group' + (link.k === 'supersede' ? ' supersede' : '') });
|
|
1078
|
+
// Two passes: a wide faint halo and the axon itself. Cheaper than a filter
|
|
1079
|
+
// and it keeps the line glowing without touching the text layer.
|
|
1080
|
+
group.appendChild(el('path', { 'class': 'edge-glow', d: d }));
|
|
1081
|
+
group.appendChild(el('path', { 'class': 'edge', d: d }));
|
|
1082
|
+
edgeLayer.appendChild(group);
|
|
1083
|
+
edgeEls.push({ g: group, a: link.s, b: link.t });
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
/* ------------------------------------------------------------ nodes */
|
|
1087
|
+
|
|
1088
|
+
nodes.forEach(function (node) {
|
|
1089
|
+
var cls = 'node ' + node.type + ' k-' + node.kind;
|
|
1090
|
+
if (node.hub) cls += ' hub';
|
|
1091
|
+
if (node.status === 'superseded') cls += ' superseded';
|
|
1092
|
+
if (node.status === 'retracted') cls += ' retracted';
|
|
1093
|
+
node.baseClass = cls;
|
|
1094
|
+
|
|
1095
|
+
var group = el('g', { 'class': cls, 'tabindex': '0', role: 'button' });
|
|
1096
|
+
group.appendChild(el('circle', {
|
|
1097
|
+
'class': 'halo',
|
|
1098
|
+
cx: node.x,
|
|
1099
|
+
cy: node.y,
|
|
1100
|
+
r: fmt(node.r * node.haloScale),
|
|
1101
|
+
fill: 'url(#glow-' + node.kind + ')'
|
|
1102
|
+
}));
|
|
1103
|
+
if (node.hub) {
|
|
1104
|
+
group.appendChild(el('circle', { 'class': 'ring', cx: node.x, cy: node.y, r: fmt(node.r + 5) }));
|
|
1105
|
+
}
|
|
1106
|
+
group.appendChild(el('circle', { 'class': 'core', cx: node.x, cy: node.y, r: node.r }));
|
|
1107
|
+
if (node.type === 'entity' && node.r >= 10) {
|
|
1108
|
+
group.appendChild(el('circle', { 'class': 'nucleus', cx: node.x, cy: node.y, r: fmt(node.r * 0.42) }));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
var title = el('title');
|
|
1112
|
+
title.textContent = node.full || node.label;
|
|
1113
|
+
group.appendChild(title);
|
|
1114
|
+
|
|
1115
|
+
group.addEventListener('pointerenter', function (event) { hover(node, event); });
|
|
1116
|
+
group.addEventListener('pointerleave', function () { unhover(); });
|
|
1117
|
+
group.addEventListener('focus', function () { hover(node, null); });
|
|
1118
|
+
group.addEventListener('blur', function () { unhover(); });
|
|
1119
|
+
group.addEventListener('click', function (event) {
|
|
1120
|
+
event.stopPropagation();
|
|
1121
|
+
select(selected === node.key ? null : node.key);
|
|
1122
|
+
});
|
|
1123
|
+
group.addEventListener('keydown', function (event) {
|
|
1124
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
1125
|
+
event.preventDefault();
|
|
1126
|
+
select(selected === node.key ? null : node.key);
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
nodeLayer.appendChild(group);
|
|
1131
|
+
|
|
1132
|
+
var label = el('text', {
|
|
1133
|
+
x: node.labelAt.x,
|
|
1134
|
+
y: node.labelAt.y,
|
|
1135
|
+
'text-anchor': node.labelAt.anchor,
|
|
1136
|
+
'dominant-baseline': 'central',
|
|
1137
|
+
'class': 'lbl ' + node.type + (node.labelAt.on ? '' : ' off')
|
|
1138
|
+
});
|
|
1139
|
+
label.textContent = node.label;
|
|
1140
|
+
labelLayer.appendChild(label);
|
|
1141
|
+
|
|
1142
|
+
node.el = group;
|
|
1143
|
+
node.lblEl = label;
|
|
1144
|
+
node.lblOn = node.labelAt.on;
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
/* ------------------------------------------------------------ tooltip */
|
|
1148
|
+
|
|
1149
|
+
function setTooltip(node, event) {
|
|
1150
|
+
tooltip.textContent = '';
|
|
1151
|
+
var kind = h('div', 'tt-kind');
|
|
1152
|
+
if (node.type === 'entity') {
|
|
1153
|
+
kind.appendChild(h('span', 'badge k-entity', '实体 · ' + (node.etype || 'concept')));
|
|
1154
|
+
} else {
|
|
1155
|
+
kind.appendChild(h('span', 'badge k-' + node.kind, DATA.kinds[node.kind] || node.kind));
|
|
1156
|
+
if (node.status !== 'active') {
|
|
1157
|
+
kind.appendChild(document.createTextNode(' '));
|
|
1158
|
+
kind.appendChild(h('span', 'badge warn plain', node.status === 'superseded' ? '已替换' : '已撤回'));
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
tooltip.appendChild(kind);
|
|
1162
|
+
tooltip.appendChild(h('div', 'tt-title', node.full || node.label));
|
|
1163
|
+
|
|
1164
|
+
if (node.type === 'entity') {
|
|
1165
|
+
var cards = Object.keys(neighbors[node.key]).filter(function (key) { return key.indexOf('c:') === 0; });
|
|
1166
|
+
tooltip.appendChild(h('div', 'tt-snip', cards.length + ' 张卡片 · ' + (node.projects.length ? node.projects.join(' / ') : '未归档')));
|
|
1167
|
+
tooltip.appendChild(h('div', 'tt-meta', 'first ' + (node.firstSeen || '—') + ' · last ' + (node.lastSeen || '—')));
|
|
1168
|
+
} else {
|
|
1169
|
+
if (node.snippet) tooltip.appendChild(h('div', 'tt-snip', node.snippet));
|
|
1170
|
+
tooltip.appendChild(h('div', 'tt-meta', node.id + ' · ' + node.project + ' · ' + node.created));
|
|
1171
|
+
}
|
|
1172
|
+
tooltip.classList.add('on');
|
|
1173
|
+
position(node, event);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function position(node, event) {
|
|
1177
|
+
var rect = stage.getBoundingClientRect();
|
|
1178
|
+
var x;
|
|
1179
|
+
var y;
|
|
1180
|
+
if (event && event.clientX) {
|
|
1181
|
+
x = event.clientX - rect.left + 16;
|
|
1182
|
+
y = event.clientY - rect.top + 14;
|
|
1183
|
+
} else {
|
|
1184
|
+
var box = node.el.getBoundingClientRect();
|
|
1185
|
+
x = box.left - rect.left + box.width / 2 + 14;
|
|
1186
|
+
y = box.top - rect.top - 8;
|
|
1187
|
+
}
|
|
1188
|
+
var w = tooltip.offsetWidth;
|
|
1189
|
+
var hh = tooltip.offsetHeight;
|
|
1190
|
+
if (x + w > rect.width - 10) x = Math.max(10, x - w - 30);
|
|
1191
|
+
if (y + hh > rect.height - 10) y = Math.max(10, y - hh - 24);
|
|
1192
|
+
tooltip.style.left = x + 'px';
|
|
1193
|
+
tooltip.style.top = y + 'px';
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
var hovered = null;
|
|
1197
|
+
function hover(node, event) {
|
|
1198
|
+
hovered = node;
|
|
1199
|
+
setTooltip(node, event);
|
|
1200
|
+
if (!node.lblOn) node.lblEl.classList.remove('off');
|
|
1201
|
+
}
|
|
1202
|
+
function unhover() {
|
|
1203
|
+
hovered = null;
|
|
1204
|
+
tooltip.classList.remove('on');
|
|
1205
|
+
refresh();
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/* ------------------------------------------------------------ panel */
|
|
1209
|
+
|
|
1210
|
+
function badge(kindClass, text, plain) {
|
|
1211
|
+
return h('span', 'badge ' + kindClass + (plain ? ' plain' : ''), text);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function chip(text, onclick) {
|
|
1215
|
+
var node = h('span', 'chip' + (onclick ? '' : ' static'), text);
|
|
1216
|
+
if (onclick) node.addEventListener('click', onclick);
|
|
1217
|
+
return node;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function renderPanel(node) {
|
|
1221
|
+
panel.textContent = '';
|
|
1222
|
+
var close = h('button', 'close', '×');
|
|
1223
|
+
close.title = '关闭 (Esc)';
|
|
1224
|
+
close.addEventListener('click', function () { select(null); });
|
|
1225
|
+
panel.appendChild(close);
|
|
1226
|
+
|
|
1227
|
+
if (node.type === 'entity') {
|
|
1228
|
+
panel.appendChild(h('div', 'eyebrow', '实体 entity · ' + (node.etype || 'concept')));
|
|
1229
|
+
panel.appendChild(h('h2', null, node.full || node.label));
|
|
1230
|
+
var row = h('div', 'row');
|
|
1231
|
+
if (node.hub) row.appendChild(badge('k-mint', '跨项目枢纽'));
|
|
1232
|
+
row.appendChild(badge('k-entity', Object.keys(neighbors[node.key]).length + ' 条连线'));
|
|
1233
|
+
panel.appendChild(row);
|
|
1234
|
+
|
|
1235
|
+
var dl = h('dl');
|
|
1236
|
+
var pairs = [
|
|
1237
|
+
['首次出现', node.firstSeen || '—'],
|
|
1238
|
+
['最近出现', node.lastSeen || '—'],
|
|
1239
|
+
['别名', (node.aliases || []).join(' · ') || '—'],
|
|
1240
|
+
['项目', (node.projects || []).join(' · ') || '—'],
|
|
1241
|
+
['id', node.slug]
|
|
1242
|
+
];
|
|
1243
|
+
pairs.forEach(function (pair) {
|
|
1244
|
+
dl.appendChild(h('dt', null, pair[0]));
|
|
1245
|
+
dl.appendChild(h('dd', null, pair[1]));
|
|
1246
|
+
});
|
|
1247
|
+
panel.appendChild(dl);
|
|
1248
|
+
|
|
1249
|
+
if (node.mentions && node.mentions.length) {
|
|
1250
|
+
panel.appendChild(h('h3', null, '提及时间线'));
|
|
1251
|
+
var list = h('ul', 'mentions');
|
|
1252
|
+
node.mentions.forEach(function (mention) {
|
|
1253
|
+
var li = h('li');
|
|
1254
|
+
li.appendChild(h('span', 'd', mention.d));
|
|
1255
|
+
li.appendChild(h('span', null, mention.note));
|
|
1256
|
+
var key = 'c:' + mention.cardId;
|
|
1257
|
+
if (byKey[key]) li.addEventListener('click', function () { select(key); });
|
|
1258
|
+
list.appendChild(li);
|
|
1259
|
+
});
|
|
1260
|
+
panel.appendChild(list);
|
|
1261
|
+
}
|
|
1262
|
+
panel.hidden = false;
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
panel.appendChild(h('div', 'eyebrow', '记忆卡片 memory card'));
|
|
1267
|
+
panel.appendChild(h('h2', null, node.full || node.label));
|
|
1268
|
+
var badges = h('div', 'row');
|
|
1269
|
+
badges.appendChild(badge('k-' + node.kind, DATA.kinds[node.kind] || node.kind));
|
|
1270
|
+
if (node.status !== 'active') {
|
|
1271
|
+
badges.appendChild(badge('warn', node.status === 'superseded' ? '已替换 superseded' : '已撤回 retracted', true));
|
|
1272
|
+
}
|
|
1273
|
+
badges.appendChild(badge('plain', node.confidence, true));
|
|
1274
|
+
panel.appendChild(badges);
|
|
1275
|
+
|
|
1276
|
+
var meta = h('dl');
|
|
1277
|
+
[['项目', node.project], ['日期', node.created], ['id', node.id]].forEach(function (pair) {
|
|
1278
|
+
meta.appendChild(h('dt', null, pair[0]));
|
|
1279
|
+
meta.appendChild(h('dd', null, pair[1]));
|
|
1280
|
+
});
|
|
1281
|
+
panel.appendChild(meta);
|
|
1282
|
+
|
|
1283
|
+
if (node.body) panel.appendChild(h('p', 'body', node.body));
|
|
1284
|
+
|
|
1285
|
+
if (node.entities && node.entities.length) {
|
|
1286
|
+
panel.appendChild(h('h3', null, '提到的实体'));
|
|
1287
|
+
var chips = h('div', 'chips');
|
|
1288
|
+
node.entities.forEach(function (entity) {
|
|
1289
|
+
var key = 'e:' + entity.slug;
|
|
1290
|
+
chips.appendChild(chip(entity.name, byKey[key] ? function () { select(key); } : null));
|
|
1291
|
+
});
|
|
1292
|
+
panel.appendChild(chips);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
var chain = []
|
|
1296
|
+
if (node.supersedes && node.supersedes.length) chain.push(['替换了', node.supersedes]);
|
|
1297
|
+
if (node.supersededBy) chain.push(['被替换为', [node.supersededBy]]);
|
|
1298
|
+
if (chain.length) {
|
|
1299
|
+
panel.appendChild(h('h3', null, '替换链'));
|
|
1300
|
+
var chainBox = h('div', 'chips');
|
|
1301
|
+
chain.forEach(function (entry) {
|
|
1302
|
+
entry[1].forEach(function (id) {
|
|
1303
|
+
var other = byKey['c:' + id];
|
|
1304
|
+
var label = entry[0] + ' ' + (other ? (other.full || other.label) : id);
|
|
1305
|
+
chainBox.appendChild(chip(label, other ? function () { select(other.key); } : null));
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
panel.appendChild(chainBox);
|
|
1309
|
+
}
|
|
1310
|
+
panel.hidden = false;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/* ------------------------------------------------------------ state */
|
|
1314
|
+
|
|
1315
|
+
function matches(node, q) {
|
|
1316
|
+
if (q === '') return true;
|
|
1317
|
+
if (String(node.label).toLowerCase().indexOf(q) !== -1) return true;
|
|
1318
|
+
if (String(node.full).toLowerCase().indexOf(q) !== -1) return true;
|
|
1319
|
+
if (node.type === 'card') {
|
|
1320
|
+
if (String(node.id).toLowerCase().indexOf(q) !== -1) return true;
|
|
1321
|
+
for (var i = 0; i < node.entities.length; i++) {
|
|
1322
|
+
if (String(node.entities[i].name).toLowerCase().indexOf(q) !== -1) return true;
|
|
1323
|
+
}
|
|
1324
|
+
} else {
|
|
1325
|
+
if (String(node.slug).toLowerCase().indexOf(q) !== -1) return true;
|
|
1326
|
+
}
|
|
1327
|
+
return false;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function refresh() {
|
|
1331
|
+
var q = searchBox.value.trim().toLowerCase();
|
|
1332
|
+
var project = projectBox.value;
|
|
1333
|
+
var filtering = q !== '' || project !== '*';
|
|
1334
|
+
var visible = {};
|
|
1335
|
+
var hitCount = 0;
|
|
1336
|
+
|
|
1337
|
+
nodes.forEach(function (node) {
|
|
1338
|
+
var cls = node.baseClass;
|
|
1339
|
+
var show = true;
|
|
1340
|
+
var hit = false;
|
|
1341
|
+
if (selected) {
|
|
1342
|
+
if (node.key === selected || neighbors[selected][node.key]) {
|
|
1343
|
+
cls += ' lit';
|
|
1344
|
+
show = true;
|
|
1345
|
+
} else {
|
|
1346
|
+
cls += ' dim';
|
|
1347
|
+
show = false;
|
|
1348
|
+
}
|
|
1349
|
+
} else if (filtering) {
|
|
1350
|
+
var okProject = project === '*' || node.projects.indexOf(project) !== -1;
|
|
1351
|
+
var okQuery = matches(node, q);
|
|
1352
|
+
if (okProject && okQuery) {
|
|
1353
|
+
if (q !== '') { cls += ' hit'; hit = true; hitCount++; }
|
|
1354
|
+
} else {
|
|
1355
|
+
cls += ' dim';
|
|
1356
|
+
show = false;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
if (node.key === selected) cls += ' sel';
|
|
1360
|
+
node.el.setAttribute('class', cls);
|
|
1361
|
+
visible[node.key] = show;
|
|
1362
|
+
|
|
1363
|
+
var labelCls = 'lbl ' + node.type + (show && (node.lblOn || hit || node.key === selected) ? '' : ' off') + (show ? '' : ' dim');
|
|
1364
|
+
node.lblEl.setAttribute('class', labelCls);
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
edgeEls.forEach(function (edge) {
|
|
1368
|
+
var cls = 'edge-group' + (String(edge.g.getAttribute('class')).indexOf('supersede') !== -1 ? ' supersede' : '');
|
|
1369
|
+
if (selected) {
|
|
1370
|
+
if (visible[edge.a] && visible[edge.b]) cls += ' lit';
|
|
1371
|
+
else cls += ' dim';
|
|
1372
|
+
} else if (!visible[edge.a] || !visible[edge.b]) {
|
|
1373
|
+
cls += ' dim';
|
|
1374
|
+
}
|
|
1375
|
+
edge.g.setAttribute('class', cls);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
var label = document.getElementById('match-bar');
|
|
1379
|
+
if (filtering && q !== '') {
|
|
1380
|
+
if (!label) {
|
|
1381
|
+
label = h('div', 'badge plain');
|
|
1382
|
+
label.id = 'match-bar';
|
|
1383
|
+
countsBox.parentNode.insertBefore(label, countsBox.nextSibling);
|
|
1384
|
+
}
|
|
1385
|
+
label.textContent = '匹配 ' + hitCount + ' / ' + nodes.length;
|
|
1386
|
+
label.hidden = false;
|
|
1387
|
+
} else if (label) {
|
|
1388
|
+
label.hidden = true;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function select(key) {
|
|
1393
|
+
selected = key;
|
|
1394
|
+
if (!key) {
|
|
1395
|
+
panel.hidden = true;
|
|
1396
|
+
refresh();
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
var node = byKey[key];
|
|
1400
|
+
if (!node) return;
|
|
1401
|
+
renderPanel(node);
|
|
1402
|
+
// Bring the node into view if it sits outside the viewport.
|
|
1403
|
+
var box = node.el.getBoundingClientRect();
|
|
1404
|
+
var frame = stage.getBoundingClientRect();
|
|
1405
|
+
if (box.left < frame.left || box.right > frame.right || box.top < frame.top || box.bottom > frame.bottom) {
|
|
1406
|
+
var ctm = canvas.getScreenCTM();
|
|
1407
|
+
view.x += (frame.left + frame.width / 2 - box.left - box.width / 2) / (ctm ? ctm.a : 1);
|
|
1408
|
+
view.y += (frame.top + frame.height / 2 - box.top - box.height / 2) / (ctm ? ctm.a : 1);
|
|
1409
|
+
applyView();
|
|
1410
|
+
}
|
|
1411
|
+
refresh();
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/* ------------------------------------------------------------ view */
|
|
1415
|
+
|
|
1416
|
+
function applyView() {
|
|
1417
|
+
viewport.setAttribute(
|
|
1418
|
+
'transform',
|
|
1419
|
+
'translate(' + fmt(view.x) + ' ' + fmt(view.y) + ') scale(' + (Math.round(view.k * 1000) / 1000) + ')'
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function toUser(event) {
|
|
1424
|
+
var ctm = canvas.getScreenCTM();
|
|
1425
|
+
var point = canvas.createSVGPoint();
|
|
1426
|
+
point.x = event.clientX;
|
|
1427
|
+
point.y = event.clientY;
|
|
1428
|
+
if (!ctm) return { x: 0, y: 0 };
|
|
1429
|
+
var local = point.matrixTransform(ctm.inverse());
|
|
1430
|
+
return { x: local.x, y: local.y };
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function zoomAt(point, factor) {
|
|
1434
|
+
var k = Math.min(4.5, Math.max(0.35, view.k * factor));
|
|
1435
|
+
var ratio = k / view.k;
|
|
1436
|
+
view.x = point.x - ratio * (point.x - view.x);
|
|
1437
|
+
view.y = point.y - ratio * (point.y - view.y);
|
|
1438
|
+
view.k = k;
|
|
1439
|
+
applyView();
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
canvas.addEventListener('wheel', function (event) {
|
|
1443
|
+
event.preventDefault();
|
|
1444
|
+
zoomAt(toUser(event), Math.exp(-event.deltaY * 0.0016));
|
|
1445
|
+
}, { passive: false });
|
|
1446
|
+
|
|
1447
|
+
document.getElementById('zoom-in').addEventListener('click', function () { zoomAt({ x: 700, y: 430 }, 1.3); });
|
|
1448
|
+
document.getElementById('zoom-out').addEventListener('click', function () { zoomAt({ x: 700, y: 430 }, 1 / 1.3); });
|
|
1449
|
+
document.getElementById('zoom-fit').addEventListener('click', function () {
|
|
1450
|
+
view = { k: 1, x: 0, y: 0 };
|
|
1451
|
+
applyView();
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
var drag = null;
|
|
1455
|
+
canvas.addEventListener('pointerdown', function (event) {
|
|
1456
|
+
if (event.target.closest && event.target.closest('.node')) return;
|
|
1457
|
+
drag = { x: event.clientX, y: event.clientY, vx: view.x, vy: view.y, moved: false };
|
|
1458
|
+
canvas.classList.add('panning');
|
|
1459
|
+
if (canvas.setPointerCapture) canvas.setPointerCapture(event.pointerId);
|
|
1460
|
+
});
|
|
1461
|
+
canvas.addEventListener('pointermove', function (event) {
|
|
1462
|
+
if (!drag) return;
|
|
1463
|
+
var ctm = canvas.getScreenCTM();
|
|
1464
|
+
var scale = ctm ? ctm.a : 1;
|
|
1465
|
+
var dx = (event.clientX - drag.x) / scale;
|
|
1466
|
+
var dy = (event.clientY - drag.y) / scale;
|
|
1467
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
|
|
1468
|
+
view.x = drag.vx + dx;
|
|
1469
|
+
view.y = drag.vy + dy;
|
|
1470
|
+
applyView();
|
|
1471
|
+
});
|
|
1472
|
+
canvas.addEventListener('pointerup', function () {
|
|
1473
|
+
var moved = drag ? drag.moved : false;
|
|
1474
|
+
drag = null;
|
|
1475
|
+
canvas.classList.remove('panning');
|
|
1476
|
+
if (!moved) select(null);
|
|
1477
|
+
});
|
|
1478
|
+
canvas.addEventListener('pointercancel', function () {
|
|
1479
|
+
drag = null;
|
|
1480
|
+
canvas.classList.remove('panning');
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
/* ------------------------------------------------------------ filters */
|
|
1484
|
+
|
|
1485
|
+
var all = h('option', null, '全部项目 · ' + DATA.counts.cards);
|
|
1486
|
+
all.value = '*';
|
|
1487
|
+
projectBox.appendChild(all);
|
|
1488
|
+
DATA.projects.forEach(function (project) {
|
|
1489
|
+
var option = h('option', null, project.slug + ' · ' + project.count);
|
|
1490
|
+
option.value = project.slug;
|
|
1491
|
+
projectBox.appendChild(option);
|
|
1492
|
+
});
|
|
1493
|
+
projectBox.addEventListener('change', refresh);
|
|
1494
|
+
searchBox.addEventListener('input', refresh);
|
|
1495
|
+
|
|
1496
|
+
document.addEventListener('keydown', function (event) {
|
|
1497
|
+
if (event.key === 'Escape') {
|
|
1498
|
+
if (document.activeElement === searchBox && searchBox.value !== '') {
|
|
1499
|
+
searchBox.value = '';
|
|
1500
|
+
refresh();
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
select(null);
|
|
1504
|
+
searchBox.blur();
|
|
1505
|
+
} else if (event.key === '/' && document.activeElement !== searchBox) {
|
|
1506
|
+
event.preventDefault();
|
|
1507
|
+
searchBox.focus();
|
|
1508
|
+
} else if (event.key === 'f' && document.activeElement !== searchBox) {
|
|
1509
|
+
view = { k: 1, x: 0, y: 0 };
|
|
1510
|
+
applyView();
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
applyView();
|
|
1515
|
+
refresh();
|
|
1516
|
+
if (pendingNode) {
|
|
1517
|
+
var linked = nodes.filter(function (node) {
|
|
1518
|
+
return node.key === pendingNode || node.id === pendingNode || node.slug === pendingNode;
|
|
1519
|
+
})[0];
|
|
1520
|
+
if (linked) select(linked.key);
|
|
1521
|
+
}
|
|
1522
|
+
})();
|
|
1523
|
+
`
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/* ---------------------------------------------------------------- render */
|
|
1527
|
+
|
|
1528
|
+
function escapeForScript(json) {
|
|
1529
|
+
// `</script>` inside a card title would end the block; < is escaped everywhere
|
|
1530
|
+
// because it only ever appears inside a JSON string.
|
|
1531
|
+
return json
|
|
1532
|
+
.replace(/</g, '\\u003c')
|
|
1533
|
+
.replace(/\u2028/g, '\\u2028')
|
|
1534
|
+
.replace(/\u2029/g, '\\u2029')
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function stamp() {
|
|
1538
|
+
const now = new Date()
|
|
1539
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
1540
|
+
return (
|
|
1541
|
+
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
|
1542
|
+
`T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
|
1543
|
+
)
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Render the whole vault as one offline HTML page.
|
|
1548
|
+
*
|
|
1549
|
+
* @param {{ cards?: object[], entities?: object[], projects?: object[], generatedAt?: string }} input
|
|
1550
|
+
* @returns {string} a complete, self-contained HTML document
|
|
1551
|
+
*/
|
|
1552
|
+
export function renderGraphHtml({ cards = [], entities = [], projects = [], generatedAt = '' } = {}) {
|
|
1553
|
+
const payload = toPayload({
|
|
1554
|
+
cards,
|
|
1555
|
+
entities,
|
|
1556
|
+
projects,
|
|
1557
|
+
generatedAt: generatedAt || stamp(),
|
|
1558
|
+
})
|
|
1559
|
+
const dataJson = escapeForScript(JSON.stringify(payload))
|
|
1560
|
+
const title = `记忆图 · ${payload.counts.cards} cards / ${payload.counts.entities} entities`
|
|
1561
|
+
|
|
1562
|
+
return `<!doctype html>
|
|
1563
|
+
<html lang="zh-CN">
|
|
1564
|
+
<head>
|
|
1565
|
+
<meta charset="utf-8">
|
|
1566
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1567
|
+
<meta name="color-scheme" content="dark light">
|
|
1568
|
+
<title>${title.replace(/</g, '<')}</title>
|
|
1569
|
+
<style>${CSS}</style>
|
|
1570
|
+
</head>
|
|
1571
|
+
<body>
|
|
1572
|
+
${BODY}
|
|
1573
|
+
<script>${clientScript(dataJson)}</script>
|
|
1574
|
+
</body>
|
|
1575
|
+
</html>
|
|
1576
|
+
`
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** Content hash of the rendered page, handy for "did anything change". */
|
|
1580
|
+
export function graphDigest(html) {
|
|
1581
|
+
return crypto.createHash('sha256').update(html, 'utf8').digest('hex').slice(0, 12)
|
|
1582
|
+
}
|