ltcai 0.1.9 → 0.1.16
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/README.md +174 -305
- package/docs/CHANGELOG.md +307 -0
- package/docs/architecture.md +121 -0
- package/docs/mcp-tools.md +116 -0
- package/docs/privacy.md +74 -0
- package/docs/public-deploy.md +137 -0
- package/docs/security-model.md +121 -0
- package/knowledge_graph.py +123 -15
- package/llm_router.py +100 -28
- package/ltcai_cli.py +138 -5
- package/package.json +14 -2
- package/server.py +1756 -329
- package/skills/SKILL_TEMPLATE.md +61 -29
- package/skills/code_review/SKILL.md +28 -0
- package/skills/code_review/examples.md +59 -0
- package/skills/code_review/risk.json +9 -0
- package/skills/code_review/schema.json +65 -0
- package/skills/data_analysis/SKILL.md +28 -0
- package/skills/data_analysis/examples.md +62 -0
- package/skills/data_analysis/risk.json +9 -0
- package/skills/data_analysis/schema.json +61 -0
- package/skills/file_edit/SKILL.md +33 -0
- package/skills/file_edit/examples.md +45 -0
- package/skills/file_edit/risk.json +9 -0
- package/skills/file_edit/schema.json +60 -0
- package/skills/summarize_document/SKILL.md +68 -0
- package/skills/summarize_document/examples.md +65 -0
- package/skills/summarize_document/risk.json +9 -0
- package/skills/summarize_document/schema.json +71 -0
- package/skills/web_search/SKILL.md +28 -0
- package/skills/web_search/examples.md +61 -0
- package/skills/web_search/risk.json +9 -0
- package/skills/web_search/schema.json +62 -0
- package/static/account.html +53 -51
- package/static/admin.html +50 -46
- package/static/chat.html +124 -96
- package/static/graph.html +1231 -337
- package/static/manifest.json +2 -2
- package/tests/integration/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__pycache__/test_api.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/test_tools.py +194 -1
- package/tools.py +264 -4
package/static/graph.html
CHANGED
|
@@ -3,112 +3,548 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>Lattice AI
|
|
6
|
+
<title>Lattice AI - Data Graph</title>
|
|
7
7
|
<style>
|
|
8
8
|
:root {
|
|
9
9
|
color-scheme: dark;
|
|
10
|
-
--bg: #
|
|
11
|
-
--
|
|
12
|
-
--
|
|
13
|
-
--
|
|
14
|
-
--
|
|
15
|
-
--
|
|
16
|
-
--
|
|
10
|
+
--bg: #282a36;
|
|
11
|
+
--bg-soft: #222431;
|
|
12
|
+
--panel: rgba(34, 36, 49, 0.76);
|
|
13
|
+
--panel-strong: rgba(31, 33, 45, 0.9);
|
|
14
|
+
--line: rgba(164, 180, 242, 0.18);
|
|
15
|
+
--line-strong: rgba(218, 225, 255, 0.28);
|
|
16
|
+
--text: #f7f7f2;
|
|
17
|
+
--muted: #c4c8d8;
|
|
18
|
+
--faint: #8d93ab;
|
|
19
|
+
--accent: #a77cff;
|
|
20
|
+
--accent-2: #20b8aa;
|
|
21
|
+
--danger: #ef7f7f;
|
|
22
|
+
--shadow: 0 18px 60px rgba(4, 6, 12, 0.34);
|
|
17
23
|
}
|
|
24
|
+
|
|
18
25
|
* { box-sizing: border-box; }
|
|
19
|
-
body {
|
|
20
|
-
|
|
26
|
+
html, body { height: 100%; }
|
|
27
|
+
body {
|
|
28
|
+
margin: 0;
|
|
29
|
+
overflow: hidden;
|
|
30
|
+
color: var(--text);
|
|
31
|
+
background:
|
|
32
|
+
radial-gradient(circle at 50% 42%, rgba(167,124,255,0.10), transparent 34%),
|
|
33
|
+
radial-gradient(circle at 70% 22%, rgba(32,184,170,0.08), transparent 28%),
|
|
34
|
+
linear-gradient(180deg, #2b2d3a, #282a36 62%, #242632);
|
|
35
|
+
font-family: "SF Pro Display", "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.app {
|
|
39
|
+
display: grid;
|
|
40
|
+
grid-template-columns: minmax(0, 1fr) 360px;
|
|
41
|
+
height: 100vh;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.stage {
|
|
45
|
+
position: relative;
|
|
46
|
+
min-width: 0;
|
|
47
|
+
border-right: 1px solid var(--line);
|
|
48
|
+
background:
|
|
49
|
+
radial-gradient(circle, rgba(247,247,242,0.62) 1px, transparent 1.9px),
|
|
50
|
+
linear-gradient(116deg, transparent 0 43%, rgba(120,145,220,0.10) 43.1%, transparent 43.28% 100%),
|
|
51
|
+
linear-gradient(28deg, transparent 0 61%, rgba(167,124,255,0.09) 61.1%, transparent 61.28% 100%);
|
|
52
|
+
background-size: 84px 84px, 100% 100%, 100% 100%;
|
|
53
|
+
background-position: 18px 22px, 0 0, 0 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
canvas {
|
|
57
|
+
display: block;
|
|
58
|
+
width: 100%;
|
|
59
|
+
height: 100%;
|
|
60
|
+
cursor: grab;
|
|
61
|
+
}
|
|
21
62
|
|
|
22
|
-
/* Canvas area */
|
|
23
|
-
.stage { position: relative; min-width: 0; border-right: 1px solid var(--line); }
|
|
24
|
-
canvas { display: block; width: 100%; height: 100%; cursor: grab; }
|
|
25
63
|
canvas.panning { cursor: grabbing; }
|
|
26
64
|
|
|
65
|
+
.search-shell,
|
|
66
|
+
.toolbar {
|
|
67
|
+
position: absolute;
|
|
68
|
+
z-index: 20;
|
|
69
|
+
border: 1px solid var(--line);
|
|
70
|
+
background: var(--panel);
|
|
71
|
+
backdrop-filter: blur(18px);
|
|
72
|
+
box-shadow: var(--shadow);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.search-shell {
|
|
76
|
+
top: 16px;
|
|
77
|
+
left: 16px;
|
|
78
|
+
width: min(360px, calc(100% - 32px));
|
|
79
|
+
border-radius: 10px;
|
|
80
|
+
overflow: hidden;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.search-head {
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
justify-content: space-between;
|
|
87
|
+
gap: 12px;
|
|
88
|
+
padding: 14px 16px 10px;
|
|
89
|
+
border-bottom: 1px solid rgba(255,255,255,0.04);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.search-title {
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-direction: column;
|
|
95
|
+
gap: 3px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.search-title strong {
|
|
99
|
+
font-size: 14px;
|
|
100
|
+
font-weight: 700;
|
|
101
|
+
letter-spacing: 0.01em;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.search-title span {
|
|
105
|
+
font-size: 12px;
|
|
106
|
+
color: var(--muted);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.search-count {
|
|
110
|
+
flex-shrink: 0;
|
|
111
|
+
font-size: 11px;
|
|
112
|
+
color: #08100d;
|
|
113
|
+
background: linear-gradient(135deg, #7df0c3, #56d4ff);
|
|
114
|
+
border-radius: 999px;
|
|
115
|
+
padding: 5px 10px;
|
|
116
|
+
font-weight: 700;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.search-input-wrap {
|
|
120
|
+
padding: 12px 16px 10px;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.search-input-row {
|
|
124
|
+
display: flex;
|
|
125
|
+
gap: 8px;
|
|
126
|
+
align-items: center;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.search-input {
|
|
130
|
+
flex: 1;
|
|
131
|
+
height: 42px;
|
|
132
|
+
border-radius: 12px;
|
|
133
|
+
border: 1px solid var(--line-strong);
|
|
134
|
+
background: rgba(6, 10, 14, 0.88);
|
|
135
|
+
color: var(--text);
|
|
136
|
+
padding: 0 14px;
|
|
137
|
+
font-size: 14px;
|
|
138
|
+
outline: none;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.search-input:focus {
|
|
142
|
+
border-color: rgba(98, 224, 176, 0.75);
|
|
143
|
+
box-shadow: 0 0 0 4px rgba(98, 224, 176, 0.12);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.icon-btn,
|
|
147
|
+
.tb-btn {
|
|
148
|
+
height: 42px;
|
|
149
|
+
border-radius: 8px;
|
|
150
|
+
border: 1px solid var(--line-strong);
|
|
151
|
+
background: rgba(255,255,255,0.03);
|
|
152
|
+
color: var(--text);
|
|
153
|
+
cursor: pointer;
|
|
154
|
+
font-size: 13px;
|
|
155
|
+
transition: 140ms ease;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.icon-btn {
|
|
159
|
+
width: 42px;
|
|
160
|
+
flex-shrink: 0;
|
|
161
|
+
font-size: 16px;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.icon-btn:hover,
|
|
165
|
+
.tb-btn:hover {
|
|
166
|
+
transform: translateY(-1px);
|
|
167
|
+
border-color: rgba(98, 224, 176, 0.6);
|
|
168
|
+
color: var(--accent);
|
|
169
|
+
background: rgba(98, 224, 176, 0.07);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.search-results {
|
|
173
|
+
max-height: min(420px, calc(100vh - 180px));
|
|
174
|
+
overflow-y: auto;
|
|
175
|
+
padding: 0 8px 10px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.search-empty,
|
|
179
|
+
.search-loading {
|
|
180
|
+
margin: 0;
|
|
181
|
+
padding: 14px 12px 16px;
|
|
182
|
+
color: var(--muted);
|
|
183
|
+
font-size: 13px;
|
|
184
|
+
line-height: 1.65;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.search-list {
|
|
188
|
+
display: flex;
|
|
189
|
+
flex-direction: column;
|
|
190
|
+
gap: 8px;
|
|
191
|
+
padding: 0 8px 8px;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.search-item {
|
|
195
|
+
width: 100%;
|
|
196
|
+
text-align: left;
|
|
197
|
+
border: 1px solid transparent;
|
|
198
|
+
border-radius: 8px;
|
|
199
|
+
background: rgba(255,255,255,0.03);
|
|
200
|
+
color: var(--text);
|
|
201
|
+
padding: 12px 12px 13px;
|
|
202
|
+
cursor: pointer;
|
|
203
|
+
transition: 140ms ease;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.search-item:hover,
|
|
207
|
+
.search-item.active {
|
|
208
|
+
border-color: rgba(122, 168, 255, 0.45);
|
|
209
|
+
background: linear-gradient(180deg, rgba(122, 168, 255, 0.10), rgba(98, 224, 176, 0.05));
|
|
210
|
+
transform: translateY(-1px);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.search-item-top {
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
gap: 8px;
|
|
217
|
+
margin-bottom: 6px;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.search-type {
|
|
221
|
+
display: inline-flex;
|
|
222
|
+
align-items: center;
|
|
223
|
+
border-radius: 999px;
|
|
224
|
+
padding: 4px 10px;
|
|
225
|
+
font-size: 11px;
|
|
226
|
+
font-weight: 700;
|
|
227
|
+
color: #091019;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.search-item-title {
|
|
231
|
+
font-size: 13px;
|
|
232
|
+
font-weight: 700;
|
|
233
|
+
letter-spacing: 0.01em;
|
|
234
|
+
color: var(--text);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.search-item-summary {
|
|
238
|
+
font-size: 12px;
|
|
239
|
+
color: var(--muted);
|
|
240
|
+
line-height: 1.55;
|
|
241
|
+
margin: 0 0 8px;
|
|
242
|
+
word-break: break-word;
|
|
243
|
+
display: -webkit-box;
|
|
244
|
+
-webkit-line-clamp: 3;
|
|
245
|
+
line-clamp: 3;
|
|
246
|
+
-webkit-box-orient: vertical;
|
|
247
|
+
overflow: hidden;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.search-item-meta {
|
|
251
|
+
font-size: 11px;
|
|
252
|
+
color: var(--faint);
|
|
253
|
+
display: flex;
|
|
254
|
+
gap: 10px;
|
|
255
|
+
flex-wrap: wrap;
|
|
256
|
+
}
|
|
257
|
+
|
|
27
258
|
.toolbar {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
border-radius: 6px; background: #0b0d10; color: var(--text);
|
|
37
|
-
padding: 0 10px; outline: none; font-size: 13px;
|
|
38
|
-
}
|
|
39
|
-
.toolbar input:focus { border-color: var(--accent); }
|
|
259
|
+
top: 16px;
|
|
260
|
+
right: 16px;
|
|
261
|
+
display: flex;
|
|
262
|
+
gap: 8px;
|
|
263
|
+
padding: 8px;
|
|
264
|
+
border-radius: 10px;
|
|
265
|
+
}
|
|
266
|
+
|
|
40
267
|
.tb-btn {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
268
|
+
padding: 0 14px;
|
|
269
|
+
min-width: 72px;
|
|
270
|
+
white-space: nowrap;
|
|
44
271
|
}
|
|
45
|
-
.tb-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
46
272
|
|
|
47
273
|
#tooltip {
|
|
48
|
-
position: fixed;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
274
|
+
position: fixed;
|
|
275
|
+
z-index: 50;
|
|
276
|
+
max-width: 300px;
|
|
277
|
+
padding: 8px 11px;
|
|
278
|
+
border-radius: 10px;
|
|
279
|
+
border: 1px solid var(--line-strong);
|
|
280
|
+
background: rgba(7, 11, 15, 0.96);
|
|
281
|
+
color: var(--text);
|
|
282
|
+
box-shadow: var(--shadow);
|
|
283
|
+
font-size: 12px;
|
|
284
|
+
line-height: 1.45;
|
|
285
|
+
pointer-events: none;
|
|
286
|
+
display: none;
|
|
287
|
+
word-break: break-word;
|
|
52
288
|
}
|
|
53
289
|
|
|
54
|
-
/* Sidebar */
|
|
55
290
|
aside {
|
|
56
|
-
|
|
291
|
+
display: flex;
|
|
292
|
+
flex-direction: column;
|
|
293
|
+
background:
|
|
294
|
+
linear-gradient(180deg, rgba(34, 36, 49, 0.88), rgba(28, 30, 41, 0.92)),
|
|
295
|
+
var(--bg-soft);
|
|
57
296
|
overflow: hidden;
|
|
58
297
|
}
|
|
298
|
+
|
|
59
299
|
.sidebar-head {
|
|
60
|
-
padding:
|
|
300
|
+
padding: 18px 18px 12px;
|
|
301
|
+
border-bottom: 1px solid rgba(255,255,255,0.04);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.eyebrow {
|
|
305
|
+
color: var(--accent);
|
|
306
|
+
font-size: 11px;
|
|
307
|
+
font-weight: 700;
|
|
308
|
+
letter-spacing: 0.12em;
|
|
309
|
+
text-transform: uppercase;
|
|
310
|
+
margin-bottom: 8px;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
h1 {
|
|
314
|
+
margin: 0;
|
|
315
|
+
font-size: 22px;
|
|
316
|
+
line-height: 1.1;
|
|
317
|
+
letter-spacing: -0.02em;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.sidebar-sub {
|
|
321
|
+
margin: 10px 0 0;
|
|
322
|
+
color: var(--muted);
|
|
323
|
+
font-size: 13px;
|
|
324
|
+
line-height: 1.65;
|
|
61
325
|
}
|
|
62
|
-
h1 { margin: 0 0 12px; font-size: 17px; font-weight: 700; }
|
|
63
326
|
|
|
64
327
|
.stats-row {
|
|
65
|
-
display: grid;
|
|
328
|
+
display: grid;
|
|
329
|
+
grid-template-columns: 1fr 1fr;
|
|
330
|
+
gap: 10px;
|
|
331
|
+
margin-top: 14px;
|
|
66
332
|
}
|
|
333
|
+
|
|
67
334
|
.stat {
|
|
68
|
-
border: 1px solid var(--line);
|
|
69
|
-
|
|
335
|
+
border: 1px solid var(--line);
|
|
336
|
+
border-radius: 8px;
|
|
337
|
+
background: rgba(6, 10, 14, 0.34);
|
|
338
|
+
padding: 12px 13px;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.stat strong {
|
|
342
|
+
display: block;
|
|
343
|
+
font-size: 24px;
|
|
344
|
+
line-height: 1;
|
|
345
|
+
margin-bottom: 5px;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.stat span {
|
|
349
|
+
display: block;
|
|
350
|
+
font-size: 11px;
|
|
351
|
+
color: var(--muted);
|
|
352
|
+
text-transform: uppercase;
|
|
353
|
+
letter-spacing: 0.08em;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.section {
|
|
357
|
+
padding: 16px 18px 0;
|
|
358
|
+
flex-shrink: 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.section-label {
|
|
362
|
+
color: var(--faint);
|
|
363
|
+
font-size: 11px;
|
|
364
|
+
text-transform: uppercase;
|
|
365
|
+
letter-spacing: 0.08em;
|
|
366
|
+
font-weight: 700;
|
|
367
|
+
margin-bottom: 10px;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.legend-grid,
|
|
371
|
+
.filter-grid {
|
|
372
|
+
display: flex;
|
|
373
|
+
flex-direction: column;
|
|
374
|
+
gap: 7px;
|
|
70
375
|
}
|
|
71
|
-
.stat strong { display: block; font-size: 20px; font-weight: 700; line-height: 1; margin-bottom: 3px; }
|
|
72
|
-
.stat span { color: var(--muted); font-size: 11px; }
|
|
73
376
|
|
|
74
|
-
.
|
|
75
|
-
.filter-
|
|
76
|
-
|
|
77
|
-
|
|
377
|
+
.legend-item,
|
|
378
|
+
.filter-item {
|
|
379
|
+
display: flex;
|
|
380
|
+
align-items: center;
|
|
381
|
+
gap: 9px;
|
|
382
|
+
min-height: 26px;
|
|
383
|
+
font-size: 12px;
|
|
78
384
|
}
|
|
79
|
-
|
|
385
|
+
|
|
80
386
|
.filter-item {
|
|
81
|
-
|
|
82
|
-
|
|
387
|
+
cursor: pointer;
|
|
388
|
+
user-select: none;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.filter-item input[type="checkbox"] {
|
|
392
|
+
width: 14px;
|
|
393
|
+
height: 14px;
|
|
394
|
+
margin: 0;
|
|
395
|
+
accent-color: var(--accent);
|
|
396
|
+
cursor: pointer;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.dot {
|
|
400
|
+
width: 10px;
|
|
401
|
+
height: 10px;
|
|
402
|
+
border-radius: 50%;
|
|
403
|
+
flex-shrink: 0;
|
|
404
|
+
box-shadow: 0 0 0 3px rgba(255,255,255,0.03);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.legend-line {
|
|
408
|
+
width: 18px;
|
|
409
|
+
height: 0;
|
|
410
|
+
border-top: 2px solid #fff;
|
|
411
|
+
opacity: 0.8;
|
|
412
|
+
flex-shrink: 0;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.filter-name,
|
|
416
|
+
.legend-name {
|
|
417
|
+
flex: 1;
|
|
418
|
+
color: var(--text);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.filter-count,
|
|
422
|
+
.legend-meta {
|
|
423
|
+
color: var(--faint);
|
|
424
|
+
font-size: 11px;
|
|
425
|
+
text-align: right;
|
|
426
|
+
white-space: nowrap;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.detail-wrap {
|
|
430
|
+
flex: 1;
|
|
431
|
+
overflow-y: auto;
|
|
432
|
+
padding: 18px;
|
|
83
433
|
}
|
|
84
|
-
.filter-item input[type="checkbox"] { width: 13px; height: 13px; cursor: pointer; accent-color: var(--accent); }
|
|
85
|
-
.dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
|
86
|
-
.filter-name { flex: 1; color: var(--text); }
|
|
87
|
-
.filter-count { color: var(--faint); font-size: 11px; min-width: 20px; text-align: right; }
|
|
88
434
|
|
|
89
|
-
.detail-wrap { flex: 1; overflow-y: auto; padding: 14px 16px 20px; }
|
|
90
435
|
.type-badge {
|
|
91
|
-
display: inline-
|
|
92
|
-
|
|
436
|
+
display: inline-flex;
|
|
437
|
+
align-items: center;
|
|
438
|
+
border-radius: 999px;
|
|
439
|
+
padding: 5px 11px;
|
|
440
|
+
font-size: 11px;
|
|
441
|
+
font-weight: 700;
|
|
442
|
+
color: #091019;
|
|
443
|
+
margin-bottom: 10px;
|
|
93
444
|
}
|
|
94
|
-
|
|
95
|
-
.detail-
|
|
96
|
-
|
|
445
|
+
|
|
446
|
+
.detail-title {
|
|
447
|
+
font-size: 19px;
|
|
448
|
+
line-height: 1.28;
|
|
449
|
+
font-weight: 750;
|
|
450
|
+
margin-bottom: 8px;
|
|
451
|
+
letter-spacing: -0.01em;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.detail-summary {
|
|
455
|
+
color: var(--muted);
|
|
456
|
+
font-size: 13px;
|
|
457
|
+
line-height: 1.7;
|
|
458
|
+
white-space: pre-wrap;
|
|
459
|
+
word-break: break-word;
|
|
460
|
+
margin-bottom: 14px;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.metric-grid {
|
|
464
|
+
display: grid;
|
|
465
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
466
|
+
gap: 10px;
|
|
467
|
+
margin-bottom: 14px;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.metric-card {
|
|
471
|
+
border: 1px solid var(--line);
|
|
472
|
+
border-radius: 8px;
|
|
473
|
+
padding: 11px 12px;
|
|
474
|
+
background: rgba(255,255,255,0.03);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.metric-card strong {
|
|
478
|
+
display: block;
|
|
479
|
+
font-size: 18px;
|
|
480
|
+
line-height: 1;
|
|
481
|
+
margin-bottom: 5px;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.metric-card span {
|
|
485
|
+
display: block;
|
|
486
|
+
font-size: 11px;
|
|
487
|
+
color: var(--muted);
|
|
488
|
+
text-transform: uppercase;
|
|
489
|
+
letter-spacing: 0.08em;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.meta-block {
|
|
493
|
+
border: 1px solid var(--line);
|
|
494
|
+
border-radius: 8px;
|
|
495
|
+
background: rgba(255,255,255,0.02);
|
|
496
|
+
padding: 12px;
|
|
497
|
+
color: var(--muted);
|
|
498
|
+
font-size: 12px;
|
|
499
|
+
line-height: 1.65;
|
|
500
|
+
white-space: pre-wrap;
|
|
501
|
+
word-break: break-word;
|
|
502
|
+
}
|
|
503
|
+
|
|
97
504
|
.jump-btn {
|
|
98
|
-
display: inline-flex;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
505
|
+
display: inline-flex;
|
|
506
|
+
align-items: center;
|
|
507
|
+
gap: 6px;
|
|
508
|
+
margin: 0 0 14px;
|
|
509
|
+
padding: 8px 14px;
|
|
510
|
+
border-radius: 999px;
|
|
511
|
+
text-decoration: none;
|
|
512
|
+
font-size: 12px;
|
|
513
|
+
font-weight: 700;
|
|
514
|
+
color: #07120d;
|
|
515
|
+
background: linear-gradient(135deg, #f7f7f2, #cfc6ff);
|
|
516
|
+
box-shadow: 0 10px 26px rgba(167, 124, 255, 0.18);
|
|
103
517
|
}
|
|
104
|
-
.jump-btn:hover { background: rgba(84,214,165,0.18); }
|
|
105
|
-
.empty-hint { color: var(--muted); font-size: 13px; line-height: 1.65; margin: 0; }
|
|
106
518
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
519
|
+
.jump-btn:hover { filter: brightness(1.04); }
|
|
520
|
+
|
|
521
|
+
.empty-hint {
|
|
522
|
+
margin: 0;
|
|
523
|
+
color: var(--muted);
|
|
524
|
+
font-size: 13px;
|
|
525
|
+
line-height: 1.8;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
@media (max-width: 900px) {
|
|
529
|
+
.app {
|
|
530
|
+
grid-template-columns: 1fr;
|
|
531
|
+
grid-template-rows: 1fr 360px;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.stage {
|
|
535
|
+
border-right: 0;
|
|
536
|
+
border-bottom: 1px solid var(--line);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.search-shell {
|
|
540
|
+
width: calc(100% - 32px);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
.toolbar {
|
|
544
|
+
top: auto;
|
|
545
|
+
bottom: 16px;
|
|
546
|
+
right: 16px;
|
|
547
|
+
}
|
|
112
548
|
}
|
|
113
549
|
</style>
|
|
114
550
|
</head>
|
|
@@ -116,72 +552,131 @@
|
|
|
116
552
|
<div class="app">
|
|
117
553
|
<main class="stage">
|
|
118
554
|
<canvas id="graph"></canvas>
|
|
555
|
+
|
|
556
|
+
<section class="search-shell">
|
|
557
|
+
<div class="search-head">
|
|
558
|
+
<div class="search-title">
|
|
559
|
+
<strong>Explore the graph</strong>
|
|
560
|
+
<span>Search topics, files, conversations, decisions, and tasks.</span>
|
|
561
|
+
</div>
|
|
562
|
+
<div class="search-count" id="search-count">Ready</div>
|
|
563
|
+
</div>
|
|
564
|
+
<div class="search-input-wrap">
|
|
565
|
+
<div class="search-input-row">
|
|
566
|
+
<input id="search" class="search-input" placeholder="Search by topic, file, or conversation..." autocomplete="off">
|
|
567
|
+
<button id="clear-search-btn" class="icon-btn" title="Clear search">×</button>
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
<div id="search-results" class="search-results">
|
|
571
|
+
<p class="search-empty">검색 결과는 여기에 표시됩니다. 키워드를 입력하면 서버 검색 결과를 불러오고, 항목을 누르면 해당 노드로 바로 이동합니다.</p>
|
|
572
|
+
</div>
|
|
573
|
+
</section>
|
|
574
|
+
|
|
119
575
|
<div class="toolbar">
|
|
120
|
-
<
|
|
121
|
-
<button class="tb-btn" id="fit-btn">Fit</button>
|
|
576
|
+
<button class="tb-btn" id="fit-btn">Fit view</button>
|
|
122
577
|
<button class="tb-btn" id="refresh-btn">Refresh</button>
|
|
123
|
-
<button class="tb-btn" id="back-btn"
|
|
578
|
+
<button class="tb-btn" id="back-btn">Back to chat</button>
|
|
124
579
|
</div>
|
|
125
580
|
</main>
|
|
581
|
+
|
|
126
582
|
<aside>
|
|
127
583
|
<div class="sidebar-head">
|
|
128
|
-
<
|
|
584
|
+
<div class="eyebrow">Data Graph</div>
|
|
585
|
+
<h1>Knowledge topology</h1>
|
|
586
|
+
<p class="sidebar-sub">주제의 크기는 중요도 기반으로, 선의 굵기와 색은 관계 종류와 강도를 반영합니다.</p>
|
|
129
587
|
<div class="stats-row">
|
|
130
|
-
<div class="stat"><strong id="node-count"
|
|
131
|
-
<div class="stat"><strong id="edge-count"
|
|
588
|
+
<div class="stat"><strong id="node-count">-</strong><span>Nodes</span></div>
|
|
589
|
+
<div class="stat"><strong id="edge-count">-</strong><span>Edges</span></div>
|
|
132
590
|
</div>
|
|
133
591
|
</div>
|
|
134
|
-
|
|
592
|
+
|
|
593
|
+
<div class="section">
|
|
594
|
+
<div class="section-label">Relationship legend</div>
|
|
595
|
+
<div id="edge-legend" class="legend-grid"></div>
|
|
596
|
+
</div>
|
|
597
|
+
|
|
598
|
+
<div class="section">
|
|
599
|
+
<div class="section-label">Node types</div>
|
|
600
|
+
<div id="type-filters" class="filter-grid"></div>
|
|
601
|
+
</div>
|
|
602
|
+
|
|
135
603
|
<div class="detail-wrap">
|
|
136
604
|
<div id="detail">
|
|
137
|
-
<p class="empty-hint"
|
|
605
|
+
<p class="empty-hint">노드를 클릭하면 요약, 중요도, 연결 강도, 메타데이터를 볼 수 있습니다. 검색 패널에서는 서버 검색 결과를 기준으로 더 정확하게 이동할 수 있습니다.</p>
|
|
138
606
|
</div>
|
|
139
607
|
</div>
|
|
140
608
|
</aside>
|
|
141
609
|
</div>
|
|
610
|
+
|
|
142
611
|
<div id="tooltip"></div>
|
|
143
612
|
|
|
144
613
|
<script>
|
|
145
614
|
const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
|
|
146
615
|
|
|
147
616
|
const TYPE_CONFIG = {
|
|
148
|
-
Conversation: { color: '#
|
|
149
|
-
Message: { color: '#
|
|
150
|
-
AIResponse: { color: '#
|
|
151
|
-
File: { color: '#
|
|
152
|
-
Topic: { color: '#
|
|
153
|
-
Person: { color: '#
|
|
154
|
-
Page: { color: '#
|
|
155
|
-
Slide: { color: '#
|
|
156
|
-
Sheet: { color: '#
|
|
157
|
-
Image: { color: '#
|
|
158
|
-
Decision: { color: '#
|
|
159
|
-
Task: { color: '#
|
|
160
|
-
ClearEvent: { color: '#
|
|
617
|
+
Conversation: { color: '#f7f7f2', label: 'Conversation' },
|
|
618
|
+
Message: { color: '#f7f7f2', label: 'Message' },
|
|
619
|
+
AIResponse: { color: '#a77cff', label: 'AI Response' },
|
|
620
|
+
File: { color: '#7db7ff', label: 'File' },
|
|
621
|
+
Topic: { color: '#a77cff', label: 'Topic' },
|
|
622
|
+
Person: { color: '#20b8aa', label: 'Person' },
|
|
623
|
+
Page: { color: '#f7f7f2', label: 'Page' },
|
|
624
|
+
Slide: { color: '#8fa3ff', label: 'Slide' },
|
|
625
|
+
Sheet: { color: '#20b8aa', label: 'Sheet' },
|
|
626
|
+
Image: { color: '#f1c86d', label: 'Image' },
|
|
627
|
+
Decision: { color: '#f1c86d', label: 'Decision' },
|
|
628
|
+
Task: { color: '#ff7db3', label: 'Task' },
|
|
629
|
+
ClearEvent: { color: '#7a6ba8', label: 'Clear Event' },
|
|
630
|
+
Event: { color: '#7a6ba8', label: 'Event' },
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const EDGE_CONFIG = {
|
|
634
|
+
contains: { color: '#7186c8', label: 'Contains', width: 1.3 },
|
|
635
|
+
authored: { color: '#20b8aa', label: 'Authored', width: 1.5 },
|
|
636
|
+
uploaded: { color: '#7db7ff', label: 'Uploaded', width: 1.5 },
|
|
637
|
+
has_event: { color: '#7a6ba8', label: 'Event', width: 1.2 },
|
|
638
|
+
triggered: { color: '#a77cff', label: 'Triggered', width: 1.2, dash: [5, 4] },
|
|
639
|
+
mentions: { color: '#aebcff', label: 'Mentions', width: 1.55 },
|
|
640
|
+
discusses: { color: '#c9b7ff', label: 'Discusses', width: 1.75 },
|
|
641
|
+
implies: { color: '#ff7db3', label: 'Implies', width: 1.55 },
|
|
642
|
+
based_on: { color: '#a77cff', label: 'Based on', width: 1.4, dash: [8, 4] },
|
|
643
|
+
contains_signal: { color: '#f1c86d', label: 'Signal', width: 1.6 },
|
|
644
|
+
has_page: { color: '#7186c8', label: 'Page', width: 1.25 },
|
|
645
|
+
has_slide: { color: '#8fa3ff', label: 'Slide', width: 1.3 },
|
|
646
|
+
has_sheet: { color: '#20b8aa', label: 'Sheet', width: 1.3 },
|
|
647
|
+
contains_image: { color: '#f1c86d', label: 'Image', width: 1.35 },
|
|
648
|
+
has_chunk: { color: '#4e566f', label: 'Chunk', width: 0.9, dash: [2, 5] },
|
|
161
649
|
};
|
|
162
650
|
|
|
163
651
|
const canvas = document.getElementById('graph');
|
|
164
652
|
const ctx = canvas.getContext('2d');
|
|
165
653
|
const detail = document.getElementById('detail');
|
|
166
|
-
const search = document.getElementById('search');
|
|
167
654
|
const tooltip = document.getElementById('tooltip');
|
|
655
|
+
const searchInput = document.getElementById('search');
|
|
656
|
+
const searchResultsEl = document.getElementById('search-results');
|
|
657
|
+
const searchCountEl = document.getElementById('search-count');
|
|
168
658
|
|
|
169
659
|
let rawGraph = { nodes: [], edges: [] };
|
|
170
|
-
let graph
|
|
660
|
+
let graph = { nodes: [], edges: [] };
|
|
171
661
|
let hiddenTypes = new Set();
|
|
172
662
|
let selected = null;
|
|
173
|
-
let hovered
|
|
663
|
+
let hovered = null;
|
|
174
664
|
let dragging = null;
|
|
175
|
-
let panning
|
|
665
|
+
let panning = null;
|
|
176
666
|
let cam = { scale: 1, tx: 0, ty: 0 };
|
|
177
667
|
let animFrameId = null;
|
|
178
|
-
let width = 0
|
|
668
|
+
let width = 0;
|
|
669
|
+
let height = 0;
|
|
670
|
+
let searchResults = [];
|
|
671
|
+
let searchResultIds = new Set();
|
|
672
|
+
let searchAbortController = null;
|
|
673
|
+
let searchDebounceId = null;
|
|
179
674
|
|
|
180
|
-
// ── Auth ─────────────────────────────────────────────────────────────────
|
|
181
675
|
function authHeaders() {
|
|
182
|
-
const
|
|
183
|
-
return
|
|
676
|
+
const token = localStorage.getItem('ltcai_session_token') || '';
|
|
677
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
184
678
|
}
|
|
679
|
+
|
|
185
680
|
function apiFetch(path, opts = {}) {
|
|
186
681
|
return fetch(`${API_BASE}${path}`, {
|
|
187
682
|
credentials: 'include',
|
|
@@ -190,156 +685,307 @@
|
|
|
190
685
|
});
|
|
191
686
|
}
|
|
192
687
|
|
|
193
|
-
|
|
688
|
+
function clamp(value, min, max) {
|
|
689
|
+
return Math.max(min, Math.min(max, value));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function escapeHtml(text) {
|
|
693
|
+
return String(text || '')
|
|
694
|
+
.replaceAll('&', '&')
|
|
695
|
+
.replaceAll('<', '<')
|
|
696
|
+
.replaceAll('>', '>')
|
|
697
|
+
.replaceAll('"', '"')
|
|
698
|
+
.replaceAll("'", ''');
|
|
699
|
+
}
|
|
700
|
+
|
|
194
701
|
function nodeColor(type) {
|
|
195
702
|
return (TYPE_CONFIG[type] || {}).color || '#8fa8bb';
|
|
196
703
|
}
|
|
197
704
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
705
|
+
function edgeStyle(type) {
|
|
706
|
+
return EDGE_CONFIG[type] || { color: '#7f8f9d', label: type, width: 1.3 };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function typeLabel(type) {
|
|
710
|
+
return (TYPE_CONFIG[type] || {}).label || type;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function formatMetric(value, digits = 2) {
|
|
714
|
+
if (value === null || value === undefined || Number.isNaN(Number(value))) return '-';
|
|
715
|
+
const num = Number(value);
|
|
716
|
+
if (Math.abs(num) >= 1000) return num.toLocaleString();
|
|
717
|
+
return Number.isInteger(num) ? String(num) : num.toFixed(digits);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function formatUpdatedAt(updatedAt) {
|
|
721
|
+
if (!updatedAt) return '';
|
|
722
|
+
const stamp = new Date(updatedAt);
|
|
723
|
+
if (Number.isNaN(stamp.getTime())) return '';
|
|
724
|
+
const diffMs = Date.now() - stamp.getTime();
|
|
725
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
726
|
+
if (diffDays <= 0) return 'today';
|
|
727
|
+
if (diffDays === 1) return '1 day ago';
|
|
728
|
+
if (diffDays < 30) return `${diffDays} days ago`;
|
|
729
|
+
const diffMonths = Math.floor(diffDays / 30);
|
|
730
|
+
if (diffMonths < 12) return `${diffMonths} mo ago`;
|
|
731
|
+
const diffYears = Math.floor(diffMonths / 12);
|
|
732
|
+
return `${diffYears} yr ago`;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function updateStats() {
|
|
736
|
+
document.getElementById('node-count').textContent = rawGraph.nodes.length.toLocaleString();
|
|
737
|
+
document.getElementById('edge-count').textContent = rawGraph.edges.length.toLocaleString();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function computeVisuals() {
|
|
741
|
+
const degreeMap = {};
|
|
742
|
+
rawGraph.edges.forEach(edge => {
|
|
743
|
+
degreeMap[edge.from] = (degreeMap[edge.from] || 0) + 1;
|
|
744
|
+
degreeMap[edge.to] = (degreeMap[edge.to] || 0) + 1;
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
rawGraph.nodes.forEach(node => {
|
|
748
|
+
const metrics = ((node.metadata || {}).graph_metrics) || {};
|
|
749
|
+
const importanceNorm = clamp(
|
|
750
|
+
Number.isFinite(Number(node.importance_norm))
|
|
751
|
+
? Number(node.importance_norm)
|
|
752
|
+
: Number(metrics.importance_norm || 0),
|
|
753
|
+
0,
|
|
754
|
+
1
|
|
755
|
+
);
|
|
756
|
+
node.degree = degreeMap[node.id] || Number(metrics.degree || 0) || 0;
|
|
757
|
+
node.importance_norm = importanceNorm;
|
|
758
|
+
node.importance = Number.isFinite(Number(node.importance))
|
|
759
|
+
? Number(node.importance)
|
|
760
|
+
: Number(metrics.importance_raw || 0);
|
|
761
|
+
|
|
762
|
+
let radius = 6;
|
|
763
|
+
if (node.type === 'Topic') {
|
|
764
|
+
radius = 10 + importanceNorm * 18 + Math.sqrt(node.degree) * 0.8;
|
|
765
|
+
} else if (node.type === 'Conversation') {
|
|
766
|
+
radius = 8 + importanceNorm * 11 + Math.sqrt(node.degree) * 0.55;
|
|
767
|
+
} else if (node.type === 'File') {
|
|
768
|
+
radius = 7 + importanceNorm * 9 + Math.sqrt(node.degree) * 0.5;
|
|
769
|
+
} else if (node.type === 'Decision' || node.type === 'Task') {
|
|
770
|
+
radius = 7 + importanceNorm * 8 + Math.sqrt(node.degree) * 0.45;
|
|
771
|
+
} else {
|
|
772
|
+
radius = 5 + importanceNorm * 7 + Math.sqrt(node.degree) * 0.35;
|
|
773
|
+
}
|
|
774
|
+
const maxRadius = node.type === 'Topic' ? 34 : 24;
|
|
775
|
+
node.r = clamp(radius, node.type === 'Topic' ? 9 : 5, maxRadius);
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function buildTypeCounts() {
|
|
780
|
+
const counts = {};
|
|
781
|
+
rawGraph.nodes.forEach(node => {
|
|
782
|
+
counts[node.type] = (counts[node.type] || 0) + 1;
|
|
204
783
|
});
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
n.r = Math.min(n.r, 22);
|
|
784
|
+
return counts;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function buildEdgeCounts() {
|
|
788
|
+
const counts = {};
|
|
789
|
+
rawGraph.edges.forEach(edge => {
|
|
790
|
+
counts[edge.type] = (counts[edge.type] || 0) + 1;
|
|
213
791
|
});
|
|
792
|
+
return counts;
|
|
214
793
|
}
|
|
215
794
|
|
|
216
795
|
function applyFilter() {
|
|
217
|
-
graph.nodes = rawGraph.nodes.filter(
|
|
218
|
-
const nodeSet = new Set(graph.nodes.map(
|
|
219
|
-
const byId = Object.fromEntries(rawGraph.nodes.map(
|
|
796
|
+
graph.nodes = rawGraph.nodes.filter(node => !hiddenTypes.has(node.type));
|
|
797
|
+
const nodeSet = new Set(graph.nodes.map(node => node.id));
|
|
798
|
+
const byId = Object.fromEntries(rawGraph.nodes.map(node => [node.id, node]));
|
|
220
799
|
graph.edges = rawGraph.edges
|
|
221
|
-
.filter(
|
|
222
|
-
.map(
|
|
800
|
+
.filter(edge => nodeSet.has(edge.from) && nodeSet.has(edge.to))
|
|
801
|
+
.map(edge => ({ ...edge, source: byId[edge.from], target: byId[edge.to] }));
|
|
223
802
|
}
|
|
224
803
|
|
|
225
804
|
function seedLayout() {
|
|
226
|
-
rawGraph.nodes.forEach((
|
|
227
|
-
if (
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
805
|
+
rawGraph.nodes.forEach((node, index) => {
|
|
806
|
+
if (node.x === undefined || node.y === undefined) {
|
|
807
|
+
const angle = (index / Math.max(1, rawGraph.nodes.length)) * Math.PI * 2;
|
|
808
|
+
const ring = Math.min(width, height) * (node.type === 'Topic' ? 0.22 : 0.32);
|
|
809
|
+
node.x = width / 2 + Math.cos(angle) * ring;
|
|
810
|
+
node.y = height / 2 + Math.sin(angle) * ring;
|
|
231
811
|
}
|
|
232
|
-
|
|
233
|
-
|
|
812
|
+
node.vx = node.vx || 0;
|
|
813
|
+
node.vy = node.vy || 0;
|
|
234
814
|
});
|
|
235
815
|
}
|
|
236
816
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
817
|
+
function mergeGraphData(extraNodes, extraEdges) {
|
|
818
|
+
const nodeMap = new Map(rawGraph.nodes.map(node => [node.id, node]));
|
|
819
|
+
extraNodes.forEach(node => {
|
|
820
|
+
const prev = nodeMap.get(node.id) || {};
|
|
821
|
+
nodeMap.set(node.id, {
|
|
822
|
+
...prev,
|
|
823
|
+
...node,
|
|
824
|
+
metadata: { ...(prev.metadata || {}), ...(node.metadata || {}) },
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
rawGraph.nodes = [...nodeMap.values()];
|
|
828
|
+
|
|
829
|
+
const edgeMap = new Map(rawGraph.edges.map(edge => [edge.id || `${edge.from}|${edge.type}|${edge.to}`, edge]));
|
|
830
|
+
extraEdges.forEach(edge => {
|
|
831
|
+
const key = edge.id || `${edge.from}|${edge.type}|${edge.to}`;
|
|
832
|
+
edgeMap.set(key, edge);
|
|
833
|
+
});
|
|
834
|
+
rawGraph.edges = [...edgeMap.values()];
|
|
835
|
+
|
|
836
|
+
computeVisuals();
|
|
837
|
+
seedLayout();
|
|
838
|
+
applyFilter();
|
|
839
|
+
updateStats();
|
|
840
|
+
renderTypeFilters(buildTypeCounts());
|
|
841
|
+
renderEdgeLegend(buildEdgeCounts());
|
|
842
|
+
}
|
|
241
843
|
|
|
242
|
-
|
|
844
|
+
async function loadGraph() {
|
|
845
|
+
updateStats();
|
|
846
|
+
const [graphRes, statsRes] = await Promise.all([
|
|
243
847
|
apiFetch('/knowledge-graph/graph?limit=600'),
|
|
244
848
|
apiFetch('/knowledge-graph/stats'),
|
|
245
849
|
]);
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
rawGraph.edges = Array.isArray(rawGraph.edges) ? rawGraph.edges : [];
|
|
252
|
-
const stats = sRes.ok ? await sRes.json() : {};
|
|
850
|
+
if (graphRes.status === 401) {
|
|
851
|
+
window.location.href = '/account';
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (!graphRes.ok) throw new Error(`Graph API failed (${graphRes.status})`);
|
|
253
855
|
|
|
254
|
-
|
|
856
|
+
const graphData = await graphRes.json();
|
|
857
|
+
const stats = statsRes.ok ? await statsRes.json() : {};
|
|
858
|
+
rawGraph = {
|
|
859
|
+
nodes: Array.isArray(graphData.nodes) ? graphData.nodes : [],
|
|
860
|
+
edges: Array.isArray(graphData.edges) ? graphData.edges : [],
|
|
861
|
+
};
|
|
862
|
+
computeVisuals();
|
|
255
863
|
seedLayout();
|
|
256
864
|
applyFilter();
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
showDetail(selected && rawGraph.nodes.find(n => n.id === selected.id) || graph.nodes[0] || null);
|
|
865
|
+
updateStats();
|
|
866
|
+
renderTypeFilters(stats.nodes || buildTypeCounts());
|
|
867
|
+
renderEdgeLegend(stats.edges || {});
|
|
868
|
+
showDetail(selected && rawGraph.nodes.find(node => node.id === selected.id) || graph.nodes[0] || null);
|
|
262
869
|
cam = { scale: 1, tx: 0, ty: 0 };
|
|
870
|
+
fitToScreen();
|
|
263
871
|
wakeUp();
|
|
264
872
|
}
|
|
265
873
|
|
|
266
|
-
// ── Type filters ──────────────────────────────────────────────────────────
|
|
267
874
|
function renderTypeFilters(typeCounts) {
|
|
268
|
-
const presentTypes = [...new Set(rawGraph.nodes.map(
|
|
269
|
-
const ordered = [...Object.keys(TYPE_CONFIG), ...presentTypes.filter(
|
|
875
|
+
const presentTypes = [...new Set(rawGraph.nodes.map(node => node.type))];
|
|
876
|
+
const ordered = [...Object.keys(TYPE_CONFIG), ...presentTypes.filter(type => !TYPE_CONFIG[type])]
|
|
877
|
+
.filter(type => presentTypes.includes(type));
|
|
270
878
|
const container = document.getElementById('type-filters');
|
|
271
|
-
if (!ordered.length) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
return
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
879
|
+
if (!ordered.length) {
|
|
880
|
+
container.innerHTML = '<div class="empty-hint">No node types yet.</div>';
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
container.innerHTML = ordered.map(type => {
|
|
884
|
+
const checked = hiddenTypes.has(type) ? '' : 'checked';
|
|
885
|
+
return `
|
|
886
|
+
<label class="filter-item">
|
|
887
|
+
<input type="checkbox" ${checked} onchange="toggleType('${type}', this.checked)">
|
|
888
|
+
<span class="dot" style="background:${nodeColor(type)}"></span>
|
|
889
|
+
<span class="filter-name">${escapeHtml(typeLabel(type))}</span>
|
|
890
|
+
<span class="filter-count">${typeCounts[type] || 0}</span>
|
|
891
|
+
</label>
|
|
892
|
+
`;
|
|
893
|
+
}).join('');
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function renderEdgeLegend(edgeCounts) {
|
|
897
|
+
const presentEdgeTypes = [...new Set(rawGraph.edges.map(edge => edge.type))];
|
|
898
|
+
const ordered = [...Object.keys(EDGE_CONFIG), ...presentEdgeTypes.filter(type => !EDGE_CONFIG[type])]
|
|
899
|
+
.filter(type => presentEdgeTypes.includes(type));
|
|
900
|
+
const container = document.getElementById('edge-legend');
|
|
901
|
+
if (!ordered.length) {
|
|
902
|
+
container.innerHTML = '<div class="empty-hint">No relationships yet.</div>';
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
container.innerHTML = ordered.map(type => {
|
|
906
|
+
const style = edgeStyle(type);
|
|
907
|
+
return `
|
|
908
|
+
<div class="legend-item">
|
|
909
|
+
<span class="legend-line" style="border-top-color:${style.color}; border-top-width:${Math.max(2, style.width)}px;"></span>
|
|
910
|
+
<span class="legend-name">${escapeHtml(style.label || type)}</span>
|
|
911
|
+
<span class="legend-meta">${edgeCounts[type] || 0}</span>
|
|
912
|
+
</div>
|
|
913
|
+
`;
|
|
283
914
|
}).join('');
|
|
284
|
-
container.innerHTML = `<div class="filter-section-label">Node types</div><div class="filter-grid">${items}</div>`;
|
|
285
915
|
}
|
|
286
916
|
|
|
287
917
|
function toggleType(type, visible) {
|
|
288
|
-
if (visible) hiddenTypes.delete(type);
|
|
918
|
+
if (visible) hiddenTypes.delete(type);
|
|
919
|
+
else hiddenTypes.add(type);
|
|
289
920
|
applyFilter();
|
|
921
|
+
if (selected && hiddenTypes.has(selected.type)) showDetail(null);
|
|
290
922
|
wakeUp();
|
|
291
923
|
}
|
|
924
|
+
window.toggleType = toggleType;
|
|
292
925
|
|
|
293
|
-
// ── Physics ───────────────────────────────────────────────────────────────
|
|
294
926
|
function step() {
|
|
295
927
|
const nodes = graph.nodes;
|
|
296
928
|
const edges = graph.edges;
|
|
929
|
+
const centerPull = selected ? 0.00035 : 0.00055;
|
|
297
930
|
|
|
298
931
|
for (let i = 0; i < nodes.length; i++) {
|
|
299
932
|
for (let j = i + 1; j < nodes.length; j++) {
|
|
300
|
-
const a = nodes[i]
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
933
|
+
const a = nodes[i];
|
|
934
|
+
const b = nodes[j];
|
|
935
|
+
const dx = a.x - b.x;
|
|
936
|
+
const dy = a.y - b.y;
|
|
937
|
+
const d2 = Math.max(120, dx * dx + dy * dy);
|
|
938
|
+
const strength = (a.type === 'Topic' || b.type === 'Topic') ? 2900 : 2100;
|
|
939
|
+
const force = strength / d2;
|
|
940
|
+
a.vx += dx * force;
|
|
941
|
+
a.vy += dy * force;
|
|
942
|
+
b.vx -= dx * force;
|
|
943
|
+
b.vy -= dy * force;
|
|
306
944
|
}
|
|
307
945
|
}
|
|
308
946
|
|
|
309
|
-
edges.forEach(
|
|
310
|
-
if (!
|
|
311
|
-
const dx =
|
|
947
|
+
edges.forEach(edge => {
|
|
948
|
+
if (!edge.source || !edge.target) return;
|
|
949
|
+
const dx = edge.target.x - edge.source.x;
|
|
950
|
+
const dy = edge.target.y - edge.source.y;
|
|
312
951
|
const dist = Math.max(1, Math.hypot(dx, dy));
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
952
|
+
const targetDistance = edge.type === 'mentions' || edge.type === 'discusses'
|
|
953
|
+
? 118
|
|
954
|
+
: edge.type === 'contains'
|
|
955
|
+
? 138
|
|
956
|
+
: 132;
|
|
957
|
+
const force = (dist - targetDistance) * (0.0038 + Math.min(0.003, (edge.weight || 1) * 0.0015));
|
|
958
|
+
edge.source.vx += (dx / dist) * force;
|
|
959
|
+
edge.source.vy += (dy / dist) * force;
|
|
960
|
+
edge.target.vx -= (dx / dist) * force;
|
|
961
|
+
edge.target.vy -= (dy / dist) * force;
|
|
317
962
|
});
|
|
318
963
|
|
|
319
|
-
let
|
|
320
|
-
nodes.forEach(
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
964
|
+
let kineticEnergy = 0;
|
|
965
|
+
nodes.forEach(node => {
|
|
966
|
+
if (node === dragging) return;
|
|
967
|
+
node.vx += (width / 2 - node.x) * centerPull;
|
|
968
|
+
node.vy += (height / 2 - node.y) * centerPull;
|
|
969
|
+
node.vx *= 0.84;
|
|
970
|
+
node.vy *= 0.84;
|
|
971
|
+
node.x += node.vx;
|
|
972
|
+
node.y += node.vy;
|
|
973
|
+
kineticEnergy += node.vx * node.vx + node.vy * node.vy;
|
|
327
974
|
});
|
|
328
|
-
return
|
|
975
|
+
return kineticEnergy;
|
|
329
976
|
}
|
|
330
977
|
|
|
331
978
|
function wakeUp() {
|
|
332
979
|
if (!animFrameId) animFrameId = requestAnimationFrame(draw);
|
|
333
980
|
}
|
|
334
981
|
|
|
335
|
-
// ── Draw ──────────────────────────────────────────────────────────────────
|
|
336
982
|
const nbCache = new Map();
|
|
337
983
|
function neighborIds(node) {
|
|
338
984
|
if (nbCache.has(node.id)) return nbCache.get(node.id);
|
|
339
985
|
const ids = new Set([node.id]);
|
|
340
|
-
graph.edges.forEach(
|
|
341
|
-
if (
|
|
342
|
-
if (
|
|
986
|
+
graph.edges.forEach(edge => {
|
|
987
|
+
if (edge.from === node.id) ids.add(edge.to);
|
|
988
|
+
if (edge.to === node.id) ids.add(edge.from);
|
|
343
989
|
});
|
|
344
990
|
nbCache.set(node.id, ids);
|
|
345
991
|
return ids;
|
|
@@ -347,7 +993,7 @@
|
|
|
347
993
|
|
|
348
994
|
function draw() {
|
|
349
995
|
animFrameId = null;
|
|
350
|
-
const
|
|
996
|
+
const kineticEnergy = step();
|
|
351
997
|
nbCache.clear();
|
|
352
998
|
|
|
353
999
|
ctx.clearRect(0, 0, width, height);
|
|
@@ -356,256 +1002,504 @@
|
|
|
356
1002
|
ctx.scale(cam.scale, cam.scale);
|
|
357
1003
|
|
|
358
1004
|
const active = hovered || selected;
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
const
|
|
367
|
-
ctx.
|
|
368
|
-
|
|
369
|
-
ctx.
|
|
1005
|
+
const neighborSet = active ? neighborIds(active) : null;
|
|
1006
|
+
|
|
1007
|
+
graph.edges.forEach(edge => {
|
|
1008
|
+
if (!edge.source || !edge.target) return;
|
|
1009
|
+
const style = edgeStyle(edge.type);
|
|
1010
|
+
const isNeighborEdge = neighborSet && neighborSet.has(edge.from) && neighborSet.has(edge.to);
|
|
1011
|
+
const baseAlpha = neighborSet ? (isNeighborEdge ? 0.88 : 0.07) : 0.34;
|
|
1012
|
+
const widthBoost = isNeighborEdge ? 0.5 : 0;
|
|
1013
|
+
ctx.save();
|
|
1014
|
+
ctx.globalAlpha = baseAlpha;
|
|
1015
|
+
ctx.strokeStyle = style.color;
|
|
1016
|
+
ctx.lineWidth = (style.width + Math.min(3.4, (edge.weight || 1) * 1.1) + widthBoost) / cam.scale;
|
|
1017
|
+
ctx.setLineDash(style.dash || []);
|
|
370
1018
|
ctx.beginPath();
|
|
371
|
-
ctx.moveTo(
|
|
372
|
-
ctx.lineTo(
|
|
1019
|
+
ctx.moveTo(edge.source.x, edge.source.y);
|
|
1020
|
+
ctx.lineTo(edge.target.x, edge.target.y);
|
|
373
1021
|
ctx.stroke();
|
|
1022
|
+
ctx.restore();
|
|
374
1023
|
});
|
|
375
1024
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
const
|
|
381
|
-
const
|
|
382
|
-
const
|
|
383
|
-
const r = n.r + (isSel ? 4 : isHov ? 2 : hit ? 2 : 0);
|
|
1025
|
+
graph.nodes.forEach(node => {
|
|
1026
|
+
const isNeighbor = neighborSet ? neighborSet.has(node.id) : true;
|
|
1027
|
+
const isSearchHit = searchResultIds.has(node.id);
|
|
1028
|
+
const isSelected = node === selected;
|
|
1029
|
+
const isHovered = node === hovered;
|
|
1030
|
+
const alpha = neighborSet ? (isNeighbor ? 1 : 0.12) : 1;
|
|
1031
|
+
const radius = node.r + (isSelected ? 4 : isHovered ? 2 : isSearchHit ? 2.6 : 0);
|
|
384
1032
|
|
|
385
1033
|
ctx.globalAlpha = alpha;
|
|
1034
|
+
|
|
1035
|
+
if (node.type === 'Topic') {
|
|
1036
|
+
const haloRadius = radius + 6 + node.importance_norm * 8;
|
|
1037
|
+
const halo = ctx.createRadialGradient(node.x, node.y, radius * 0.4, node.x, node.y, haloRadius);
|
|
1038
|
+
halo.addColorStop(0, `${nodeColor(node.type)}30`);
|
|
1039
|
+
halo.addColorStop(1, `${nodeColor(node.type)}00`);
|
|
1040
|
+
ctx.fillStyle = halo;
|
|
1041
|
+
ctx.beginPath();
|
|
1042
|
+
ctx.arc(node.x, node.y, haloRadius, 0, Math.PI * 2);
|
|
1043
|
+
ctx.fill();
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
ctx.fillStyle = isSelected ? '#ffffff' : nodeColor(node.type);
|
|
386
1047
|
ctx.beginPath();
|
|
387
|
-
ctx.arc(
|
|
388
|
-
ctx.fillStyle = isSel ? '#ffffff' : nodeColor(n.type);
|
|
1048
|
+
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
|
|
389
1049
|
ctx.fill();
|
|
390
1050
|
|
|
391
|
-
if (
|
|
392
|
-
ctx.strokeStyle =
|
|
393
|
-
ctx.lineWidth = (
|
|
1051
|
+
if (isSelected || isHovered || isSearchHit) {
|
|
1052
|
+
ctx.strokeStyle = isSelected ? '#ffffff' : nodeColor(node.type);
|
|
1053
|
+
ctx.lineWidth = (isSelected ? 2.8 : 1.8) / cam.scale;
|
|
394
1054
|
ctx.beginPath();
|
|
395
|
-
ctx.arc(
|
|
1055
|
+
ctx.arc(node.x, node.y, radius + 3.5, 0, Math.PI * 2);
|
|
396
1056
|
ctx.stroke();
|
|
397
1057
|
}
|
|
398
1058
|
|
|
399
|
-
const showLabel =
|
|
400
|
-
||
|
|
401
|
-
||
|
|
402
|
-
|| (
|
|
1059
|
+
const showLabel = isSelected || isHovered || isSearchHit
|
|
1060
|
+
|| node.type === 'Conversation'
|
|
1061
|
+
|| node.type === 'File'
|
|
1062
|
+
|| (node.type === 'Topic' && (node.importance_norm > 0.45 || cam.scale > 0.52))
|
|
1063
|
+
|| (node.type === 'Decision' || node.type === 'Task');
|
|
403
1064
|
if (showLabel) {
|
|
404
|
-
ctx.fillStyle = alpha < 0.5 ? 'rgba(
|
|
405
|
-
ctx.font = `${Math.max(9, 11 / cam.scale)}px system-ui`;
|
|
406
|
-
ctx.fillText(
|
|
1065
|
+
ctx.fillStyle = alpha < 0.5 ? 'rgba(237,244,251,0.3)' : '#edf4fb';
|
|
1066
|
+
ctx.font = `${Math.max(9, 11.5 / cam.scale)}px system-ui`;
|
|
1067
|
+
ctx.fillText(node.title.slice(0, 40), node.x + radius + 6 / cam.scale, node.y + 4 / cam.scale);
|
|
407
1068
|
}
|
|
1069
|
+
|
|
408
1070
|
ctx.globalAlpha = 1;
|
|
409
1071
|
});
|
|
410
1072
|
|
|
411
1073
|
ctx.restore();
|
|
412
|
-
if (
|
|
1074
|
+
if (kineticEnergy > 0.04 || dragging) animFrameId = requestAnimationFrame(draw);
|
|
413
1075
|
}
|
|
414
1076
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
return { x: (cx - cam.tx) / cam.scale, y: (cy - cam.ty) / cam.scale };
|
|
1077
|
+
function toWorld(canvasX, canvasY) {
|
|
1078
|
+
return { x: (canvasX - cam.tx) / cam.scale, y: (canvasY - cam.ty) / cam.scale };
|
|
418
1079
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
1080
|
+
|
|
1081
|
+
function nodeAt(canvasX, canvasY) {
|
|
1082
|
+
const { x, y } = toWorld(canvasX, canvasY);
|
|
1083
|
+
let best = null;
|
|
1084
|
+
let bestDistance = Infinity;
|
|
1085
|
+
graph.nodes.forEach(node => {
|
|
1086
|
+
const distance = Math.hypot(node.x - x, node.y - y);
|
|
1087
|
+
if (distance < (node.r + 10) / cam.scale && distance < bestDistance) {
|
|
1088
|
+
best = node;
|
|
1089
|
+
bestDistance = distance;
|
|
1090
|
+
}
|
|
425
1091
|
});
|
|
426
1092
|
return best;
|
|
427
1093
|
}
|
|
428
1094
|
|
|
429
|
-
// ── Fit to screen ─────────────────────────────────────────────────────────
|
|
430
1095
|
function fitToScreen() {
|
|
431
1096
|
if (!graph.nodes.length) return;
|
|
432
1097
|
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
433
|
-
graph.nodes.forEach(
|
|
434
|
-
x0 = Math.min(x0,
|
|
435
|
-
|
|
1098
|
+
graph.nodes.forEach(node => {
|
|
1099
|
+
x0 = Math.min(x0, node.x - node.r);
|
|
1100
|
+
x1 = Math.max(x1, node.x + node.r);
|
|
1101
|
+
y0 = Math.min(y0, node.y - node.r);
|
|
1102
|
+
y1 = Math.max(y1, node.y + node.r);
|
|
436
1103
|
});
|
|
437
|
-
const margin =
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
1104
|
+
const margin = 56;
|
|
1105
|
+
const scale = Math.min(
|
|
1106
|
+
2.8,
|
|
1107
|
+
Math.min(
|
|
1108
|
+
(width - margin * 2) / Math.max(1, x1 - x0),
|
|
1109
|
+
(height - margin * 2) / Math.max(1, y1 - y0)
|
|
1110
|
+
)
|
|
1111
|
+
);
|
|
1112
|
+
cam.scale = scale;
|
|
1113
|
+
cam.tx = (width - (x0 + x1) * scale) / 2;
|
|
1114
|
+
cam.ty = (height - (y0 + y1) * scale) / 2;
|
|
445
1115
|
wakeUp();
|
|
446
1116
|
}
|
|
447
1117
|
|
|
448
|
-
|
|
1118
|
+
function centerOnNode(node, targetScale = cam.scale) {
|
|
1119
|
+
cam.scale = clamp(targetScale, 0.12, 4.5);
|
|
1120
|
+
cam.tx = width / 2 - node.x * cam.scale;
|
|
1121
|
+
cam.ty = height / 2 - node.y * cam.scale;
|
|
1122
|
+
wakeUp();
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function metricCards(node) {
|
|
1126
|
+
const metrics = ((node.metadata || {}).graph_metrics) || {};
|
|
1127
|
+
const cards = [
|
|
1128
|
+
{ value: formatMetric(metrics.importance_norm ? metrics.importance_norm * 100 : 0, 0), label: 'Importance %' },
|
|
1129
|
+
{ value: formatMetric(metrics.degree || node.degree || 0, 0), label: 'Connections' },
|
|
1130
|
+
];
|
|
1131
|
+
if (node.type === 'Topic') {
|
|
1132
|
+
cards.push({ value: formatMetric(metrics.mention_count || 0, 0), label: 'Mentions' });
|
|
1133
|
+
cards.push({ value: formatMetric(metrics.conversation_count || 0, 0), label: 'Conversations' });
|
|
1134
|
+
} else {
|
|
1135
|
+
cards.push({ value: formatMetric(metrics.recency_score || 0), label: 'Recency' });
|
|
1136
|
+
cards.push({ value: formatMetric(node.importance || metrics.importance_raw || 0), label: 'Raw score' });
|
|
1137
|
+
}
|
|
1138
|
+
return `<div class="metric-grid">${cards.map(card => `
|
|
1139
|
+
<div class="metric-card">
|
|
1140
|
+
<strong>${escapeHtml(card.value)}</strong>
|
|
1141
|
+
<span>${escapeHtml(card.label)}</span>
|
|
1142
|
+
</div>
|
|
1143
|
+
`).join('')}</div>`;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
449
1146
|
function showDetail(node) {
|
|
450
1147
|
if (!node) {
|
|
451
|
-
|
|
1148
|
+
selected = null;
|
|
1149
|
+
detail.innerHTML = '<p class="empty-hint">노드를 클릭하면 요약, 중요도, 메타데이터를 볼 수 있습니다.</p>';
|
|
1150
|
+
wakeUp();
|
|
452
1151
|
return;
|
|
453
1152
|
}
|
|
454
1153
|
selected = node;
|
|
455
|
-
const meta
|
|
1154
|
+
const meta = node.metadata || {};
|
|
456
1155
|
const convId = meta.conversation_id;
|
|
457
1156
|
const jumpHtml = convId
|
|
458
|
-
? `<a class="jump-btn" href="${API_BASE}/chat?open_conversation=${encodeURIComponent(convId)}"
|
|
1157
|
+
? `<a class="jump-btn" href="${API_BASE}/chat?open_conversation=${encodeURIComponent(convId)}">Open in chat</a>`
|
|
459
1158
|
: '';
|
|
460
|
-
const
|
|
1159
|
+
const metrics = metricCards(node);
|
|
1160
|
+
const updatedAt = formatUpdatedAt(node.updated_at);
|
|
1161
|
+
const source = meta.filename || meta.conversation_id || meta.source || '';
|
|
1162
|
+
const metadataStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
|
|
461
1163
|
detail.innerHTML = `
|
|
462
|
-
<div class="type-badge" style="background:${nodeColor(node.type)}
|
|
463
|
-
<div class="detail-title"
|
|
464
|
-
${node.summary ?
|
|
1164
|
+
<div class="type-badge" style="background:${nodeColor(node.type)}">${escapeHtml(typeLabel(node.type))}</div>
|
|
1165
|
+
<div class="detail-title">${escapeHtml(node.title || node.id)}</div>
|
|
1166
|
+
${node.summary ? `<div class="detail-summary">${escapeHtml(node.summary)}</div>` : ''}
|
|
465
1167
|
${jumpHtml}
|
|
466
|
-
${
|
|
1168
|
+
${metrics}
|
|
1169
|
+
<div class="detail-summary">
|
|
1170
|
+
${source ? `<strong>source:</strong> ${escapeHtml(source)}<br>` : ''}
|
|
1171
|
+
${updatedAt ? `<strong>updated:</strong> ${escapeHtml(updatedAt)}` : ''}
|
|
1172
|
+
</div>
|
|
1173
|
+
${metadataStr ? `<div class="meta-block">${escapeHtml(metadataStr)}</div>` : ''}
|
|
467
1174
|
`;
|
|
468
|
-
|
|
469
|
-
if (node.summary) detail.querySelector('.detail-summary').textContent = node.summary;
|
|
470
|
-
if (metaStr) detail.querySelector('.detail-meta').textContent = metaStr;
|
|
1175
|
+
wakeUp();
|
|
471
1176
|
}
|
|
472
1177
|
|
|
473
|
-
// ── Canvas resize ─────────────────────────────────────────────────────────
|
|
474
1178
|
function resize() {
|
|
475
1179
|
const rect = canvas.getBoundingClientRect();
|
|
476
|
-
width = rect.width;
|
|
1180
|
+
width = rect.width;
|
|
1181
|
+
height = rect.height;
|
|
477
1182
|
const dpr = window.devicePixelRatio || 1;
|
|
478
|
-
canvas.width
|
|
1183
|
+
canvas.width = Math.floor(width * dpr);
|
|
479
1184
|
canvas.height = Math.floor(height * dpr);
|
|
480
1185
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
481
1186
|
}
|
|
482
1187
|
|
|
483
|
-
|
|
484
|
-
|
|
1188
|
+
function setSearchIdleState(message = 'Ready') {
|
|
1189
|
+
searchCountEl.textContent = message;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function renderSearchResults() {
|
|
1193
|
+
if (!searchInput.value.trim()) {
|
|
1194
|
+
searchResultsEl.innerHTML = '<p class="search-empty">검색 결과는 여기에 표시됩니다. 키워드를 입력하면 서버 검색 결과를 불러오고, 항목을 누르면 해당 노드로 바로 이동합니다.</p>';
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (!searchResults.length) {
|
|
1198
|
+
searchResultsEl.innerHTML = '<p class="search-empty">일치하는 노드를 찾지 못했습니다. 더 구체적인 주제어, 파일명, 대화 제목으로 다시 시도해 보세요.</p>';
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
searchResultsEl.innerHTML = `
|
|
1202
|
+
<div class="search-list">
|
|
1203
|
+
${searchResults.map(match => {
|
|
1204
|
+
const active = selected && selected.id === match.id ? 'active' : '';
|
|
1205
|
+
const source = (match.metadata || {}).filename || (match.metadata || {}).conversation_id || '';
|
|
1206
|
+
return `
|
|
1207
|
+
<button class="search-item ${active}" data-node-id="${escapeHtml(match.id)}">
|
|
1208
|
+
<div class="search-item-top">
|
|
1209
|
+
<span class="search-type" style="background:${nodeColor(match.type)}">${escapeHtml(typeLabel(match.type))}</span>
|
|
1210
|
+
<span class="search-item-title">${escapeHtml(match.title || match.id)}</span>
|
|
1211
|
+
</div>
|
|
1212
|
+
${match.summary ? `<p class="search-item-summary">${escapeHtml(match.summary)}</p>` : ''}
|
|
1213
|
+
<div class="search-item-meta">
|
|
1214
|
+
${source ? `<span>${escapeHtml(source)}</span>` : ''}
|
|
1215
|
+
${match.updated_at ? `<span>${escapeHtml(formatUpdatedAt(match.updated_at))}</span>` : ''}
|
|
1216
|
+
</div>
|
|
1217
|
+
</button>
|
|
1218
|
+
`;
|
|
1219
|
+
}).join('')}
|
|
1220
|
+
</div>
|
|
1221
|
+
`;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function runSearch(query) {
|
|
1225
|
+
const trimmed = String(query || '').trim();
|
|
1226
|
+
if (!trimmed) {
|
|
1227
|
+
searchResults = [];
|
|
1228
|
+
searchResultIds = new Set();
|
|
1229
|
+
setSearchIdleState('Ready');
|
|
1230
|
+
renderSearchResults();
|
|
1231
|
+
wakeUp();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
if (searchAbortController) searchAbortController.abort();
|
|
1236
|
+
searchAbortController = new AbortController();
|
|
1237
|
+
searchCountEl.textContent = 'Searching...';
|
|
1238
|
+
searchResultsEl.innerHTML = '<p class="search-loading">Searching graph index...</p>';
|
|
1239
|
+
|
|
1240
|
+
try {
|
|
1241
|
+
const res = await apiFetch(`/knowledge-graph/search?q=${encodeURIComponent(trimmed)}&limit=12`, {
|
|
1242
|
+
signal: searchAbortController.signal,
|
|
1243
|
+
});
|
|
1244
|
+
if (!res.ok) throw new Error(`Search failed (${res.status})`);
|
|
1245
|
+
const data = await res.json();
|
|
1246
|
+
searchResults = Array.isArray(data.matches) ? data.matches : [];
|
|
1247
|
+
searchResultIds = new Set(searchResults.map(match => match.id));
|
|
1248
|
+
searchCountEl.textContent = `${searchResults.length} result${searchResults.length === 1 ? '' : 's'}`;
|
|
1249
|
+
renderSearchResults();
|
|
1250
|
+
wakeUp();
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
if (error.name === 'AbortError') return;
|
|
1253
|
+
searchResults = [];
|
|
1254
|
+
searchResultIds = new Set();
|
|
1255
|
+
searchCountEl.textContent = 'Error';
|
|
1256
|
+
searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
|
|
1257
|
+
wakeUp();
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function scheduleSearch() {
|
|
1262
|
+
clearTimeout(searchDebounceId);
|
|
1263
|
+
searchDebounceId = setTimeout(() => runSearch(searchInput.value), 160);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function clearSearch() {
|
|
1267
|
+
searchInput.value = '';
|
|
1268
|
+
searchResults = [];
|
|
1269
|
+
searchResultIds = new Set();
|
|
1270
|
+
setSearchIdleState('Ready');
|
|
1271
|
+
renderSearchResults();
|
|
1272
|
+
wakeUp();
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async function focusSearchResult(match) {
|
|
1276
|
+
let node = rawGraph.nodes.find(item => item.id === match.id);
|
|
1277
|
+
if (!node) {
|
|
1278
|
+
const res = await apiFetch(`/knowledge-graph/neighbors/${encodeURIComponent(match.id)}`);
|
|
1279
|
+
if (res.ok) {
|
|
1280
|
+
const payload = await res.json();
|
|
1281
|
+
mergeGraphData([
|
|
1282
|
+
{
|
|
1283
|
+
id: match.id,
|
|
1284
|
+
type: match.type,
|
|
1285
|
+
title: match.title,
|
|
1286
|
+
summary: match.summary,
|
|
1287
|
+
metadata: match.metadata,
|
|
1288
|
+
updated_at: match.updated_at,
|
|
1289
|
+
},
|
|
1290
|
+
...((payload.neighbors || []).map(nodeItem => ({
|
|
1291
|
+
...nodeItem,
|
|
1292
|
+
updated_at: nodeItem.updated_at,
|
|
1293
|
+
}))),
|
|
1294
|
+
], payload.edges || []);
|
|
1295
|
+
node = rawGraph.nodes.find(item => item.id === match.id);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (!node) return;
|
|
1299
|
+
showDetail(node);
|
|
1300
|
+
centerOnNode(node, Math.max(cam.scale, node.type === 'Topic' ? 1.15 : 0.95));
|
|
1301
|
+
renderSearchResults();
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
canvas.addEventListener('mousedown', event => {
|
|
485
1305
|
const rect = canvas.getBoundingClientRect();
|
|
486
|
-
const
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
1306
|
+
const canvasX = event.clientX - rect.left;
|
|
1307
|
+
const canvasY = event.clientY - rect.top;
|
|
1308
|
+
const node = nodeAt(canvasX, canvasY);
|
|
1309
|
+
if (node) {
|
|
1310
|
+
dragging = node;
|
|
1311
|
+
showDetail(node);
|
|
1312
|
+
} else {
|
|
1313
|
+
panning = { sx: event.clientX, sy: event.clientY, tx0: cam.tx, ty0: cam.ty };
|
|
1314
|
+
canvas.classList.add('panning');
|
|
1315
|
+
}
|
|
1316
|
+
wakeUp();
|
|
490
1317
|
});
|
|
491
1318
|
|
|
492
|
-
canvas.addEventListener('mousemove',
|
|
1319
|
+
canvas.addEventListener('mousemove', event => {
|
|
493
1320
|
const rect = canvas.getBoundingClientRect();
|
|
494
|
-
const
|
|
495
|
-
if (
|
|
496
|
-
|
|
497
|
-
|
|
1321
|
+
const node = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
|
|
1322
|
+
if (node !== hovered) {
|
|
1323
|
+
hovered = node;
|
|
1324
|
+
wakeUp();
|
|
1325
|
+
}
|
|
1326
|
+
canvas.style.cursor = panning ? 'grabbing' : (node ? 'pointer' : 'grab');
|
|
1327
|
+
if (node) {
|
|
1328
|
+
const metrics = ((node.metadata || {}).graph_metrics) || {};
|
|
498
1329
|
tooltip.style.display = 'block';
|
|
499
|
-
tooltip.style.left =
|
|
500
|
-
tooltip.style.top
|
|
501
|
-
tooltip.
|
|
502
|
-
|
|
1330
|
+
tooltip.style.left = `${event.clientX + 14}px`;
|
|
1331
|
+
tooltip.style.top = `${event.clientY - 8}px`;
|
|
1332
|
+
tooltip.innerHTML = `
|
|
1333
|
+
<strong>${escapeHtml(node.title)}</strong><br>
|
|
1334
|
+
${escapeHtml(typeLabel(node.type))} · importance ${escapeHtml(formatMetric((node.importance_norm || 0) * 100, 0))}%<br>
|
|
1335
|
+
${node.type === 'Topic'
|
|
1336
|
+
? `mentions ${escapeHtml(formatMetric(metrics.mention_count || 0, 0))} · conversations ${escapeHtml(formatMetric(metrics.conversation_count || 0, 0))}`
|
|
1337
|
+
: `connections ${escapeHtml(formatMetric(metrics.degree || node.degree || 0, 0))}`
|
|
1338
|
+
}
|
|
1339
|
+
`;
|
|
1340
|
+
} else {
|
|
1341
|
+
tooltip.style.display = 'none';
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
canvas.addEventListener('mouseleave', () => {
|
|
1346
|
+
hovered = null;
|
|
1347
|
+
tooltip.style.display = 'none';
|
|
1348
|
+
wakeUp();
|
|
503
1349
|
});
|
|
504
|
-
canvas.addEventListener('mouseleave', () => { hovered = null; tooltip.style.display = 'none'; wakeUp(); });
|
|
505
1350
|
|
|
506
|
-
window.addEventListener('mousemove',
|
|
1351
|
+
window.addEventListener('mousemove', event => {
|
|
507
1352
|
if (dragging) {
|
|
508
1353
|
const rect = canvas.getBoundingClientRect();
|
|
509
|
-
const
|
|
510
|
-
dragging.x =
|
|
1354
|
+
const world = toWorld(event.clientX - rect.left, event.clientY - rect.top);
|
|
1355
|
+
dragging.x = world.x;
|
|
1356
|
+
dragging.y = world.y;
|
|
1357
|
+
dragging.vx = 0;
|
|
1358
|
+
dragging.vy = 0;
|
|
511
1359
|
wakeUp();
|
|
512
1360
|
} else if (panning) {
|
|
513
|
-
cam.tx = panning.tx0 + (
|
|
514
|
-
cam.ty = panning.ty0 + (
|
|
1361
|
+
cam.tx = panning.tx0 + (event.clientX - panning.sx);
|
|
1362
|
+
cam.ty = panning.ty0 + (event.clientY - panning.sy);
|
|
515
1363
|
wakeUp();
|
|
516
1364
|
}
|
|
517
1365
|
});
|
|
518
1366
|
|
|
519
1367
|
window.addEventListener('mouseup', () => {
|
|
520
|
-
dragging = null;
|
|
1368
|
+
dragging = null;
|
|
1369
|
+
panning = null;
|
|
521
1370
|
canvas.classList.remove('panning');
|
|
522
1371
|
});
|
|
523
1372
|
|
|
524
|
-
canvas.addEventListener('wheel',
|
|
525
|
-
|
|
1373
|
+
canvas.addEventListener('wheel', event => {
|
|
1374
|
+
event.preventDefault();
|
|
526
1375
|
const rect = canvas.getBoundingClientRect();
|
|
527
|
-
const
|
|
528
|
-
const
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
cam.
|
|
532
|
-
cam.
|
|
1376
|
+
const canvasX = event.clientX - rect.left;
|
|
1377
|
+
const canvasY = event.clientY - rect.top;
|
|
1378
|
+
const zoomFactor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
|
|
1379
|
+
const nextScale = clamp(cam.scale * zoomFactor, 0.07, 6);
|
|
1380
|
+
cam.tx = canvasX - (canvasX - cam.tx) * (nextScale / cam.scale);
|
|
1381
|
+
cam.ty = canvasY - (canvasY - cam.ty) * (nextScale / cam.scale);
|
|
1382
|
+
cam.scale = nextScale;
|
|
533
1383
|
wakeUp();
|
|
534
1384
|
}, { passive: false });
|
|
535
1385
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
e.touches[0].clientY - e.touches[1].clientY,
|
|
1386
|
+
let lastTouchDistance = null;
|
|
1387
|
+
canvas.addEventListener('touchstart', event => {
|
|
1388
|
+
event.preventDefault();
|
|
1389
|
+
if (event.touches.length === 2) {
|
|
1390
|
+
lastTouchDistance = Math.hypot(
|
|
1391
|
+
event.touches[0].clientX - event.touches[1].clientX,
|
|
1392
|
+
event.touches[0].clientY - event.touches[1].clientY
|
|
544
1393
|
);
|
|
545
1394
|
dragging = null;
|
|
546
1395
|
return;
|
|
547
1396
|
}
|
|
548
|
-
const
|
|
1397
|
+
const touch = event.touches[0];
|
|
549
1398
|
const rect = canvas.getBoundingClientRect();
|
|
550
|
-
const
|
|
551
|
-
if (
|
|
552
|
-
|
|
1399
|
+
const node = nodeAt(touch.clientX - rect.left, touch.clientY - rect.top);
|
|
1400
|
+
if (node) {
|
|
1401
|
+
dragging = node;
|
|
1402
|
+
showDetail(node);
|
|
1403
|
+
} else {
|
|
1404
|
+
panning = { sx: touch.clientX, sy: touch.clientY, tx0: cam.tx, ty0: cam.ty };
|
|
1405
|
+
}
|
|
1406
|
+
wakeUp();
|
|
553
1407
|
}, { passive: false });
|
|
554
1408
|
|
|
555
|
-
canvas.addEventListener('touchmove',
|
|
556
|
-
|
|
557
|
-
if (
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
1409
|
+
canvas.addEventListener('touchmove', event => {
|
|
1410
|
+
event.preventDefault();
|
|
1411
|
+
if (event.touches.length === 2) {
|
|
1412
|
+
const distance = Math.hypot(
|
|
1413
|
+
event.touches[0].clientX - event.touches[1].clientX,
|
|
1414
|
+
event.touches[0].clientY - event.touches[1].clientY
|
|
561
1415
|
);
|
|
562
|
-
if (
|
|
563
|
-
const
|
|
564
|
-
const
|
|
565
|
-
const
|
|
1416
|
+
if (lastTouchDistance) {
|
|
1417
|
+
const factor = distance / lastTouchDistance;
|
|
1418
|
+
const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
|
|
1419
|
+
const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
|
|
566
1420
|
const rect = canvas.getBoundingClientRect();
|
|
567
|
-
const px =
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
cam.
|
|
571
|
-
cam.
|
|
1421
|
+
const px = centerX - rect.left;
|
|
1422
|
+
const py = centerY - rect.top;
|
|
1423
|
+
const nextScale = clamp(cam.scale * factor, 0.07, 6);
|
|
1424
|
+
cam.tx = px - (px - cam.tx) * (nextScale / cam.scale);
|
|
1425
|
+
cam.ty = py - (py - cam.ty) * (nextScale / cam.scale);
|
|
1426
|
+
cam.scale = nextScale;
|
|
572
1427
|
wakeUp();
|
|
573
1428
|
}
|
|
574
|
-
|
|
1429
|
+
lastTouchDistance = distance;
|
|
575
1430
|
return;
|
|
576
1431
|
}
|
|
577
|
-
|
|
1432
|
+
|
|
1433
|
+
const touch = event.touches[0];
|
|
578
1434
|
if (dragging) {
|
|
579
1435
|
const rect = canvas.getBoundingClientRect();
|
|
580
|
-
const
|
|
581
|
-
dragging.x =
|
|
582
|
-
|
|
1436
|
+
const world = toWorld(touch.clientX - rect.left, touch.clientY - rect.top);
|
|
1437
|
+
dragging.x = world.x;
|
|
1438
|
+
dragging.y = world.y;
|
|
1439
|
+
dragging.vx = 0;
|
|
1440
|
+
dragging.vy = 0;
|
|
583
1441
|
} else if (panning) {
|
|
584
|
-
cam.tx = panning.tx0 + (
|
|
585
|
-
cam.ty = panning.ty0 + (
|
|
586
|
-
wakeUp();
|
|
1442
|
+
cam.tx = panning.tx0 + (touch.clientX - panning.sx);
|
|
1443
|
+
cam.ty = panning.ty0 + (touch.clientY - panning.sy);
|
|
587
1444
|
}
|
|
1445
|
+
wakeUp();
|
|
588
1446
|
}, { passive: false });
|
|
589
1447
|
|
|
590
|
-
canvas.addEventListener('touchend', () => {
|
|
1448
|
+
canvas.addEventListener('touchend', () => {
|
|
1449
|
+
dragging = null;
|
|
1450
|
+
panning = null;
|
|
1451
|
+
lastTouchDistance = null;
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
searchInput.addEventListener('input', scheduleSearch);
|
|
1455
|
+
searchInput.addEventListener('keydown', event => {
|
|
1456
|
+
if (event.key === 'Enter' && searchResults.length) {
|
|
1457
|
+
event.preventDefault();
|
|
1458
|
+
focusSearchResult(searchResults[0]).catch(error => {
|
|
1459
|
+
searchCountEl.textContent = 'Error';
|
|
1460
|
+
searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
591
1464
|
|
|
592
|
-
|
|
593
|
-
search.addEventListener('input', wakeUp);
|
|
1465
|
+
document.getElementById('clear-search-btn').addEventListener('click', clearSearch);
|
|
594
1466
|
document.getElementById('fit-btn').addEventListener('click', fitToScreen);
|
|
595
1467
|
document.getElementById('refresh-btn').addEventListener('click', () => {
|
|
596
1468
|
rawGraph = { nodes: [], edges: [] };
|
|
597
|
-
graph
|
|
598
|
-
|
|
599
|
-
|
|
1469
|
+
graph = { nodes: [], edges: [] };
|
|
1470
|
+
selected = null;
|
|
1471
|
+
loadGraph().catch(error => {
|
|
1472
|
+
detail.innerHTML = `<div class="type-badge" style="background:${nodeColor('ClearEvent')}; color:#091019">Error</div><div class="detail-title">그래프를 새로고침하지 못했습니다.</div><div class="detail-summary">${escapeHtml(error.message)}</div>`;
|
|
600
1473
|
});
|
|
601
1474
|
});
|
|
602
|
-
document.getElementById('back-btn').addEventListener('click', () =>
|
|
603
|
-
|
|
1475
|
+
document.getElementById('back-btn').addEventListener('click', () => {
|
|
1476
|
+
window.location.href = `${API_BASE}/chat`;
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
searchResultsEl.addEventListener('click', event => {
|
|
1480
|
+
const target = event.target.closest('[data-node-id]');
|
|
1481
|
+
if (!target) return;
|
|
1482
|
+
const match = searchResults.find(item => item.id === target.dataset.nodeId);
|
|
1483
|
+
if (!match) return;
|
|
1484
|
+
focusSearchResult(match).catch(error => {
|
|
1485
|
+
searchCountEl.textContent = 'Error';
|
|
1486
|
+
searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
|
|
1487
|
+
});
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
window.addEventListener('resize', () => {
|
|
1491
|
+
resize();
|
|
1492
|
+
wakeUp();
|
|
1493
|
+
});
|
|
604
1494
|
|
|
605
|
-
// ── Init ──────────────────────────────────────────────────────────────────
|
|
606
1495
|
resize();
|
|
607
|
-
|
|
608
|
-
|
|
1496
|
+
renderSearchResults();
|
|
1497
|
+
loadGraph().catch(error => {
|
|
1498
|
+
detail.innerHTML = `
|
|
1499
|
+
<div class="type-badge" style="background:${nodeColor('ClearEvent')}">Error</div>
|
|
1500
|
+
<div class="detail-title">그래프를 불러오지 못했습니다.</div>
|
|
1501
|
+
<div class="detail-summary">${escapeHtml(error.message)}</div>
|
|
1502
|
+
`;
|
|
609
1503
|
});
|
|
610
1504
|
</script>
|
|
611
1505
|
</body>
|