ltcai 0.1.28 → 0.1.29

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