ltcai 0.1.28 → 0.1.30

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.
@@ -0,0 +1,1059 @@
1
+ /* Lattice AI — graph.html scripts */
2
+
3
+ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
4
+
5
+ const G18N = {
6
+ ko: {
7
+ nav_home: '홈', nav_graph: '지식 그래프', nav_chat: '대화', nav_files: '파일', nav_code: '코드', nav_settings: '설정',
8
+ project: '프로젝트', search_title: '그래프 탐색', search_sub: '주제, 파일, 대화, 결정, 작업을 검색하세요.',
9
+ ready: '준비됨', search_ph: '주제, 파일, 대화로 검색...', clear_search: '검색 지우기',
10
+ search_results: '{n}개 결과',
11
+ search_empty: '검색 결과는 여기에 표시됩니다. 키워드를 입력하면 서버 검색 결과를 불러오고, 항목을 누르면 해당 노드로 바로 이동합니다.',
12
+ search_no_results: '일치하는 노드를 찾지 못했습니다. 더 구체적인 주제어, 파일명, 대화 제목으로 다시 시도해 보세요.',
13
+ searching: '검색 중...', search_loading: '그래프 인덱스를 검색하는 중...',
14
+ sidebar_eyebrow: '지식 그래프', sidebar_title: '지식 토폴로지',
15
+ sidebar_sub: '주제의 크기는 중요도 기반으로, 선의 굵기와 색은 관계 종류와 강도를 반영합니다.',
16
+ nodes: '노드', edges: '연결', relationship_legend: '관계 범례', node_types: '노드 유형',
17
+ detail_empty: '노드를 클릭하면 요약, 중요도, 연결 강도, 메타데이터를 볼 수 있습니다. 검색 패널에서는 서버 검색 결과를 기준으로 더 정확하게 이동할 수 있습니다.',
18
+ detail_empty_short: '노드를 클릭하면 요약, 중요도, 메타데이터를 볼 수 있습니다.',
19
+ refresh: '새로고침', error: '오류', graph_load_fail: '그래프를 불러오지 못했습니다.', graph_refresh_fail: '그래프를 새로고침하지 못했습니다.',
20
+ no_node_types: '아직 노드 유형이 없습니다.', no_relationships: '아직 관계가 없습니다.',
21
+ open_in_chat: '채팅에서 열기', today: '오늘', day_ago: '1일 전', days_ago: '{n}일 전', months_ago: '{n}개월 전', years_ago: '{n}년 전',
22
+ },
23
+ en: {
24
+ nav_home: 'Home', nav_graph: 'Knowledge Graph', nav_chat: 'Chat', nav_files: 'Files', nav_code: 'Code', nav_settings: 'Settings',
25
+ project: 'Project', search_title: 'Explore the graph', search_sub: 'Search topics, files, conversations, decisions, and tasks.',
26
+ ready: 'Ready', search_ph: 'Search by topic, file, or conversation...', clear_search: 'Clear search',
27
+ search_results: '{n} result(s)',
28
+ search_empty: 'Search results appear here. Enter a keyword to load server results and jump directly to a node.',
29
+ search_no_results: 'No matching nodes found. Try a more specific topic, filename, or conversation title.',
30
+ searching: 'Searching...', search_loading: 'Searching graph index...',
31
+ sidebar_eyebrow: 'Knowledge Graph', sidebar_title: 'Knowledge topology',
32
+ sidebar_sub: 'Topic size follows importance; line width and color reflect relationship type and strength.',
33
+ nodes: 'Nodes', edges: 'Edges', relationship_legend: 'Relationship legend', node_types: 'Node types',
34
+ detail_empty: 'Click a node to see its summary, importance, connection strength, and metadata. Search results can jump to more precise nodes.',
35
+ detail_empty_short: 'Click a node to see its summary, importance, and metadata.',
36
+ refresh: 'Refresh', error: 'Error', graph_load_fail: 'Could not load the graph.', graph_refresh_fail: 'Could not refresh the graph.',
37
+ no_node_types: 'No node types yet.', no_relationships: 'No relationships yet.',
38
+ open_in_chat: 'Open in chat', today: 'today', day_ago: '1 day ago', days_ago: '{n} days ago', months_ago: '{n} mo ago', years_ago: '{n} yr ago',
39
+ }
40
+ };
41
+
42
+ let currentLang = localStorage.getItem('ltcai_lang') || 'ko';
43
+ function t(key) { return (G18N[currentLang] || G18N.ko)[key] || key; }
44
+
45
+ function applyI18n() {
46
+ document.documentElement.lang = currentLang;
47
+ const navLabels = ['nav_home', 'nav_graph', 'nav_chat', 'nav_files', 'nav_code', 'nav_settings'];
48
+ document.querySelectorAll('.graph-rail nav a').forEach((link, index) => {
49
+ const icon = link.querySelector('i')?.outerHTML || '';
50
+ link.innerHTML = `${icon} ${t(navLabels[index])}`;
51
+ });
52
+ const projectLabel = document.querySelector('.rail-project span');
53
+ if (projectLabel) projectLabel.textContent = t('project');
54
+ document.querySelector('.search-title strong').textContent = t('search_title');
55
+ document.querySelector('.search-title span').textContent = t('search_sub');
56
+ searchInput.placeholder = t('search_ph');
57
+ document.getElementById('clear-search-btn').title = t('clear_search');
58
+ document.querySelector('.eyebrow').textContent = t('sidebar_eyebrow');
59
+ document.querySelector('.sidebar-head h1').textContent = t('sidebar_title');
60
+ document.querySelector('.sidebar-sub').textContent = t('sidebar_sub');
61
+ document.querySelectorAll('.stat span')[0].textContent = t('nodes');
62
+ document.querySelectorAll('.stat span')[1].textContent = t('edges');
63
+ document.querySelectorAll('.section-label')[0].textContent = t('relationship_legend');
64
+ document.querySelectorAll('.section-label')[1].textContent = t('node_types');
65
+ document.getElementById('refresh-btn').textContent = `↺ ${t('refresh')}`;
66
+ const langBtn = document.getElementById('graph-lang-btn');
67
+ if (langBtn) langBtn.textContent = `Language: ${currentLang === 'ko' ? '한국어' : 'English'}`;
68
+ ['ko', 'en'].forEach(lang => {
69
+ const el = document.getElementById(`graph-lang-${lang}`);
70
+ if (el) el.classList.toggle('active', lang === currentLang);
71
+ });
72
+ }
73
+
74
+ function toggleLangMenu(pickerId) {
75
+ const menu = document.getElementById(`${pickerId}-menu`);
76
+ if (!menu) return;
77
+ const isOpen = menu.classList.contains('open');
78
+ document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
79
+ if (!isOpen) menu.classList.add('open');
80
+ }
81
+
82
+ function setLang(lang) {
83
+ currentLang = lang;
84
+ localStorage.setItem('ltcai_lang', lang);
85
+ document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
86
+ applyI18n();
87
+ setSearchIdleState(searchInput.value.trim() ? searchCountEl.textContent : t('ready'));
88
+ renderSearchResults();
89
+ renderTypeFilters(buildTypeCounts());
90
+ renderEdgeLegend(buildEdgeCounts());
91
+ showDetail(selected);
92
+ }
93
+ window.toggleLangMenu = toggleLangMenu;
94
+ window.setLang = setLang;
95
+
96
+ const TYPE_CONFIG = {
97
+ Conversation: { color: '#9b8af0', label: 'Conversation' },
98
+ Message: { color: '#b8a9f5', label: 'Message' },
99
+ AIResponse: { color: '#6f42e8', label: 'AI Response' },
100
+ File: { color: '#5b9cf6', label: 'File' },
101
+ Topic: { color: '#7c3aed', label: 'Topic' },
102
+ Person: { color: '#0d9488', label: 'Person' },
103
+ Page: { color: '#a78bfa', label: 'Page' },
104
+ Slide: { color: '#818cf8', label: 'Slide' },
105
+ Sheet: { color: '#059669', label: 'Sheet' },
106
+ Image: { color: '#d97706', label: 'Image' },
107
+ Decision: { color: '#f59e0b', label: 'Decision' },
108
+ Task: { color: '#ec4899', label: 'Task' },
109
+ ClearEvent: { color: '#6366f1', label: 'Clear Event' },
110
+ Event: { color: '#8b5cf6', label: 'Event' },
111
+ };
112
+
113
+ const EDGE_CONFIG = {
114
+ contains: { color: '#7186c8', label: 'Contains', width: 1.3 },
115
+ authored: { color: '#20b8aa', label: 'Authored', width: 1.5 },
116
+ uploaded: { color: '#7db7ff', label: 'Uploaded', width: 1.5 },
117
+ has_event: { color: '#7a6ba8', label: 'Event', width: 1.2 },
118
+ triggered: { color: '#a77cff', label: 'Triggered', width: 1.2, dash: [5, 4] },
119
+ mentions: { color: '#aebcff', label: 'Mentions', width: 1.55 },
120
+ discusses: { color: '#c9b7ff', label: 'Discusses', width: 1.75 },
121
+ implies: { color: '#ff7db3', label: 'Implies', width: 1.55 },
122
+ based_on: { color: '#a77cff', label: 'Based on', width: 1.4, dash: [8, 4] },
123
+ contains_signal: { color: '#f1c86d', label: 'Signal', width: 1.6 },
124
+ has_page: { color: '#7186c8', label: 'Page', width: 1.25 },
125
+ has_slide: { color: '#8fa3ff', label: 'Slide', width: 1.3 },
126
+ has_sheet: { color: '#20b8aa', label: 'Sheet', width: 1.3 },
127
+ contains_image: { color: '#f1c86d', label: 'Image', width: 1.35 },
128
+ has_chunk: { color: '#4e566f', label: 'Chunk', width: 0.9, dash: [2, 5] },
129
+ };
130
+
131
+ const canvas = document.getElementById('graph');
132
+ const ctx = canvas.getContext('2d');
133
+ const detail = document.getElementById('detail');
134
+ const tooltip = document.getElementById('tooltip');
135
+ const searchInput = document.getElementById('search');
136
+ const searchResultsEl = document.getElementById('search-results');
137
+ const searchCountEl = document.getElementById('search-count');
138
+
139
+ let rawGraph = { nodes: [], edges: [] };
140
+ let graph = { nodes: [], edges: [] };
141
+ let hiddenTypes = new Set();
142
+ let selected = null;
143
+ let hovered = null;
144
+ let dragging = null;
145
+ let panning = null;
146
+ let cam = { scale: 1, tx: 0, ty: 0 };
147
+ let animFrameId = null;
148
+ let width = 0;
149
+ let height = 0;
150
+ let searchResults = [];
151
+ let searchResultIds = new Set();
152
+ let searchAbortController = null;
153
+ let searchDebounceId = null;
154
+
155
+ function apiFetch(path, opts = {}) {
156
+ return fetch(`${API_BASE}${path}`, {
157
+ credentials: 'include',
158
+ ...opts,
159
+ headers: { ...(opts.headers || {}) },
160
+ });
161
+ }
162
+
163
+ function clamp(value, min, max) {
164
+ return Math.max(min, Math.min(max, value));
165
+ }
166
+
167
+ function escapeHtml(text) {
168
+ return String(text || '')
169
+ .replaceAll('&', '&')
170
+ .replaceAll('<', '&lt;')
171
+ .replaceAll('>', '&gt;')
172
+ .replaceAll('"', '&quot;')
173
+ .replaceAll("'", '&#39;');
174
+ }
175
+
176
+ function nodeColor(type) {
177
+ return (TYPE_CONFIG[type] || {}).color || '#8fa8bb';
178
+ }
179
+
180
+ function edgeStyle(type) {
181
+ return EDGE_CONFIG[type] || { color: '#7f8f9d', label: type, width: 1.3 };
182
+ }
183
+
184
+ function typeLabel(type) {
185
+ return (TYPE_CONFIG[type] || {}).label || type;
186
+ }
187
+
188
+ function formatMetric(value, digits = 2) {
189
+ if (value === null || value === undefined || Number.isNaN(Number(value))) return '-';
190
+ const num = Number(value);
191
+ if (Math.abs(num) >= 1000) return num.toLocaleString();
192
+ return Number.isInteger(num) ? String(num) : num.toFixed(digits);
193
+ }
194
+
195
+ function formatUpdatedAt(updatedAt) {
196
+ if (!updatedAt) return '';
197
+ const stamp = new Date(updatedAt);
198
+ if (Number.isNaN(stamp.getTime())) return '';
199
+ const diffMs = Date.now() - stamp.getTime();
200
+ const diffDays = Math.floor(diffMs / 86400000);
201
+ if (diffDays <= 0) return t('today');
202
+ if (diffDays === 1) return t('day_ago');
203
+ if (diffDays < 30) return t('days_ago').replace('{n}', diffDays);
204
+ const diffMonths = Math.floor(diffDays / 30);
205
+ if (diffMonths < 12) return t('months_ago').replace('{n}', diffMonths);
206
+ const diffYears = Math.floor(diffMonths / 12);
207
+ return t('years_ago').replace('{n}', diffYears);
208
+ }
209
+
210
+ function updateStats() {
211
+ document.getElementById('node-count').textContent = rawGraph.nodes.length.toLocaleString();
212
+ document.getElementById('edge-count').textContent = rawGraph.edges.length.toLocaleString();
213
+ }
214
+
215
+ function computeVisuals() {
216
+ const degreeMap = {};
217
+ rawGraph.edges.forEach(edge => {
218
+ degreeMap[edge.from] = (degreeMap[edge.from] || 0) + 1;
219
+ degreeMap[edge.to] = (degreeMap[edge.to] || 0) + 1;
220
+ });
221
+
222
+ rawGraph.nodes.forEach(node => {
223
+ const metrics = ((node.metadata || {}).graph_metrics) || {};
224
+ const importanceNorm = clamp(
225
+ Number.isFinite(Number(node.importance_norm))
226
+ ? Number(node.importance_norm)
227
+ : Number(metrics.importance_norm || 0),
228
+ 0,
229
+ 1
230
+ );
231
+ node.degree = degreeMap[node.id] || Number(metrics.degree || 0) || 0;
232
+ node.importance_norm = importanceNorm;
233
+ node.importance = Number.isFinite(Number(node.importance))
234
+ ? Number(node.importance)
235
+ : Number(metrics.importance_raw || 0);
236
+
237
+ let radius = 10;
238
+ if (node.type === 'Topic') {
239
+ radius = 20 + importanceNorm * 24 + Math.sqrt(node.degree) * 1.2;
240
+ } else if (node.type === 'Conversation') {
241
+ radius = 16 + importanceNorm * 14 + Math.sqrt(node.degree) * 0.8;
242
+ } else if (node.type === 'File') {
243
+ radius = 15 + importanceNorm * 12 + Math.sqrt(node.degree) * 0.7;
244
+ } else if (node.type === 'Decision' || node.type === 'Task') {
245
+ radius = 14 + importanceNorm * 11 + Math.sqrt(node.degree) * 0.65;
246
+ } else {
247
+ radius = 13 + importanceNorm * 9 + Math.sqrt(node.degree) * 0.5;
248
+ }
249
+ const maxRadius = node.type === 'Topic' ? 52 : 38;
250
+ node.r = clamp(radius, node.type === 'Topic' ? 18 : 12, maxRadius);
251
+ });
252
+ }
253
+
254
+ function buildTypeCounts() {
255
+ const counts = {};
256
+ rawGraph.nodes.forEach(node => {
257
+ counts[node.type] = (counts[node.type] || 0) + 1;
258
+ });
259
+ return counts;
260
+ }
261
+
262
+ function buildEdgeCounts() {
263
+ const counts = {};
264
+ rawGraph.edges.forEach(edge => {
265
+ counts[edge.type] = (counts[edge.type] || 0) + 1;
266
+ });
267
+ return counts;
268
+ }
269
+
270
+ function applyFilter() {
271
+ graph.nodes = rawGraph.nodes.filter(node => !hiddenTypes.has(node.type));
272
+ const nodeSet = new Set(graph.nodes.map(node => node.id));
273
+ const byId = Object.fromEntries(rawGraph.nodes.map(node => [node.id, node]));
274
+ graph.edges = rawGraph.edges
275
+ .filter(edge => nodeSet.has(edge.from) && nodeSet.has(edge.to))
276
+ .map(edge => ({ ...edge, source: byId[edge.from], target: byId[edge.to] }));
277
+ }
278
+
279
+ function seedLayout() {
280
+ rawGraph.nodes.forEach((node, index) => {
281
+ if (node.x === undefined || node.y === undefined) {
282
+ const angle = (index / Math.max(1, rawGraph.nodes.length)) * Math.PI * 2;
283
+ const ring = Math.min(width, height) * (node.type === 'Topic' ? 0.22 : 0.32);
284
+ node.x = width / 2 + Math.cos(angle) * ring;
285
+ node.y = height / 2 + Math.sin(angle) * ring;
286
+ }
287
+ node.vx = node.vx || 0;
288
+ node.vy = node.vy || 0;
289
+ node._pinned = false;
290
+ });
291
+ }
292
+
293
+ /* 방사형(허브-스포크) 레이아웃 — 최고 연결 노드를 중심에 고정 */
294
+ function radialLayout() {
295
+ const nodes = rawGraph.nodes;
296
+ if (!nodes.length) return;
297
+
298
+ nodes.forEach(n => { n._pinned = false; });
299
+
300
+ // 가장 연결이 많은 노드 찾기
301
+ const deg = {};
302
+ rawGraph.edges.forEach(e => {
303
+ deg[e.from] = (deg[e.from] || 0) + 1;
304
+ deg[e.to] = (deg[e.to] || 0) + 1;
305
+ });
306
+ const sorted = [...nodes].sort((a, b) =>
307
+ ((deg[b.id] || 0) + (b.importance_norm || 0) * 5) -
308
+ ((deg[a.id] || 0) + (a.importance_norm || 0) * 5)
309
+ );
310
+
311
+ const hub = sorted[0];
312
+ const others = sorted.slice(1);
313
+
314
+ const cx = width / 2;
315
+ const cy = height / 2;
316
+
317
+ // 허브 노드 중앙 고정
318
+ hub.x = cx; hub.y = cy;
319
+ hub.vx = 0; hub.vy = 0;
320
+ hub._pinned = true;
321
+
322
+ // 나머지를 1~2개 링에 배치
323
+ const INNER_MAX = Math.min(others.length, 10);
324
+ const innerNodes = others.slice(0, INNER_MAX);
325
+ const outerNodes = others.slice(INNER_MAX);
326
+
327
+ const shortSide = Math.min(width, height);
328
+ const innerR = shortSide * 0.27;
329
+ const outerR = shortSide * 0.46;
330
+
331
+ innerNodes.forEach((node, i) => {
332
+ const angle = (i / innerNodes.length) * Math.PI * 2 - Math.PI / 2;
333
+ node.x = cx + Math.cos(angle) * innerR;
334
+ node.y = cy + Math.sin(angle) * innerR;
335
+ node.vx = 0; node.vy = 0;
336
+ });
337
+
338
+ outerNodes.forEach((node, i) => {
339
+ const angle = (i / outerNodes.length) * Math.PI * 2 - Math.PI / 2;
340
+ node.x = cx + Math.cos(angle) * outerR;
341
+ node.y = cy + Math.sin(angle) * outerR;
342
+ node.vx = 0; node.vy = 0;
343
+ });
344
+ }
345
+
346
+ function mergeGraphData(extraNodes, extraEdges) {
347
+ const nodeMap = new Map(rawGraph.nodes.map(node => [node.id, node]));
348
+ extraNodes.forEach(node => {
349
+ const prev = nodeMap.get(node.id) || {};
350
+ nodeMap.set(node.id, {
351
+ ...prev,
352
+ ...node,
353
+ metadata: { ...(prev.metadata || {}), ...(node.metadata || {}) },
354
+ });
355
+ });
356
+ rawGraph.nodes = [...nodeMap.values()];
357
+
358
+ const edgeMap = new Map(rawGraph.edges.map(edge => [edge.id || `${edge.from}|${edge.type}|${edge.to}`, edge]));
359
+ extraEdges.forEach(edge => {
360
+ const key = edge.id || `${edge.from}|${edge.type}|${edge.to}`;
361
+ edgeMap.set(key, edge);
362
+ });
363
+ rawGraph.edges = [...edgeMap.values()];
364
+
365
+ computeVisuals();
366
+ seedLayout();
367
+ applyFilter();
368
+ updateStats();
369
+ renderTypeFilters(buildTypeCounts());
370
+ renderEdgeLegend(buildEdgeCounts());
371
+ }
372
+
373
+ async function loadGraph() {
374
+ updateStats();
375
+ const [graphRes, statsRes] = await Promise.all([
376
+ apiFetch('/knowledge-graph/graph?limit=600'),
377
+ apiFetch('/knowledge-graph/stats'),
378
+ ]);
379
+ if (graphRes.status === 401) {
380
+ window.location.href = '/account';
381
+ return;
382
+ }
383
+ if (!graphRes.ok) throw new Error(`Graph API failed (${graphRes.status})`);
384
+
385
+ const graphData = await graphRes.json();
386
+ const stats = statsRes.ok ? await statsRes.json() : {};
387
+ rawGraph = {
388
+ nodes: Array.isArray(graphData.nodes) ? graphData.nodes : [],
389
+ edges: Array.isArray(graphData.edges) ? graphData.edges : [],
390
+ };
391
+ computeVisuals();
392
+ seedLayout();
393
+ radialLayout();
394
+ applyFilter();
395
+ updateStats();
396
+ renderTypeFilters(stats.nodes || buildTypeCounts());
397
+ renderEdgeLegend(stats.edges || {});
398
+ showDetail(selected && rawGraph.nodes.find(node => node.id === selected.id) || graph.nodes[0] || null);
399
+ cam = { scale: 1, tx: 0, ty: 0 };
400
+ wakeUp();
401
+ }
402
+
403
+ function renderTypeFilters(typeCounts) {
404
+ const presentTypes = [...new Set(rawGraph.nodes.map(node => node.type))];
405
+ const ordered = [...Object.keys(TYPE_CONFIG), ...presentTypes.filter(type => !TYPE_CONFIG[type])]
406
+ .filter(type => presentTypes.includes(type));
407
+ const container = document.getElementById('type-filters');
408
+ if (!ordered.length) {
409
+ container.innerHTML = `<div class="empty-hint">${t('no_node_types')}</div>`;
410
+ return;
411
+ }
412
+ container.innerHTML = ordered.map(type => {
413
+ const checked = hiddenTypes.has(type) ? '' : 'checked';
414
+ return `
415
+ <label class="filter-item">
416
+ <input type="checkbox" ${checked} onchange="toggleType('${type}', this.checked)">
417
+ <span class="dot" style="background:${nodeColor(type)}"></span>
418
+ <span class="filter-name">${escapeHtml(typeLabel(type))}</span>
419
+ <span class="filter-count">${typeCounts[type] || 0}</span>
420
+ </label>
421
+ `;
422
+ }).join('');
423
+ }
424
+
425
+ function renderEdgeLegend(edgeCounts) {
426
+ const presentEdgeTypes = [...new Set(rawGraph.edges.map(edge => edge.type))];
427
+ const ordered = [...Object.keys(EDGE_CONFIG), ...presentEdgeTypes.filter(type => !EDGE_CONFIG[type])]
428
+ .filter(type => presentEdgeTypes.includes(type));
429
+ const container = document.getElementById('edge-legend');
430
+ if (!ordered.length) {
431
+ container.innerHTML = `<div class="empty-hint">${t('no_relationships')}</div>`;
432
+ return;
433
+ }
434
+ container.innerHTML = ordered.map(type => {
435
+ const style = edgeStyle(type);
436
+ return `
437
+ <div class="legend-item">
438
+ <span class="legend-line" style="border-top-color:${style.color}; border-top-width:${Math.max(2, style.width)}px;"></span>
439
+ <span class="legend-name">${escapeHtml(style.label || type)}</span>
440
+ <span class="legend-meta">${edgeCounts[type] || 0}</span>
441
+ </div>
442
+ `;
443
+ }).join('');
444
+ }
445
+
446
+ function toggleType(type, visible) {
447
+ if (visible) hiddenTypes.delete(type);
448
+ else hiddenTypes.add(type);
449
+ applyFilter();
450
+ if (selected && hiddenTypes.has(selected.type)) showDetail(null);
451
+ wakeUp();
452
+ }
453
+ window.toggleType = toggleType;
454
+
455
+ function step() {
456
+ const nodes = graph.nodes;
457
+ const edges = graph.edges;
458
+ const centerPull = selected ? 0.00035 : 0.00055;
459
+
460
+ for (let i = 0; i < nodes.length; i++) {
461
+ for (let j = i + 1; j < nodes.length; j++) {
462
+ const a = nodes[i];
463
+ const b = nodes[j];
464
+ const dx = a.x - b.x;
465
+ const dy = a.y - b.y;
466
+ const d2 = Math.max(120, dx * dx + dy * dy);
467
+ const strength = (a.type === 'Topic' || b.type === 'Topic') ? 2900 : 2100;
468
+ const force = strength / d2;
469
+ a.vx += dx * force;
470
+ a.vy += dy * force;
471
+ b.vx -= dx * force;
472
+ b.vy -= dy * force;
473
+ }
474
+ }
475
+
476
+ edges.forEach(edge => {
477
+ if (!edge.source || !edge.target) return;
478
+ const dx = edge.target.x - edge.source.x;
479
+ const dy = edge.target.y - edge.source.y;
480
+ const dist = Math.max(1, Math.hypot(dx, dy));
481
+ const targetDistance = edge.type === 'mentions' || edge.type === 'discusses'
482
+ ? 118
483
+ : edge.type === 'contains'
484
+ ? 138
485
+ : 132;
486
+ const force = (dist - targetDistance) * (0.0038 + Math.min(0.003, (edge.weight || 1) * 0.0015));
487
+ edge.source.vx += (dx / dist) * force;
488
+ edge.source.vy += (dy / dist) * force;
489
+ edge.target.vx -= (dx / dist) * force;
490
+ edge.target.vy -= (dy / dist) * force;
491
+ });
492
+
493
+ let kineticEnergy = 0;
494
+ nodes.forEach(node => {
495
+ if (node === dragging) return;
496
+ const pull = node._pinned ? 0.55 : centerPull;
497
+ node.vx += (width / 2 - node.x) * pull;
498
+ node.vy += (height / 2 - node.y) * pull;
499
+ node.vx *= 0.84;
500
+ node.vy *= 0.84;
501
+ node.x += node.vx;
502
+ node.y += node.vy;
503
+ kineticEnergy += node.vx * node.vx + node.vy * node.vy;
504
+ });
505
+ return kineticEnergy;
506
+ }
507
+
508
+ function wakeUp() {
509
+ if (!animFrameId) animFrameId = requestAnimationFrame(draw);
510
+ }
511
+
512
+ const nbCache = new Map();
513
+ function neighborIds(node) {
514
+ if (nbCache.has(node.id)) return nbCache.get(node.id);
515
+ const ids = new Set([node.id]);
516
+ graph.edges.forEach(edge => {
517
+ if (edge.from === node.id) ids.add(edge.to);
518
+ if (edge.to === node.id) ids.add(edge.from);
519
+ });
520
+ nbCache.set(node.id, ids);
521
+ return ids;
522
+ }
523
+
524
+ function draw() {
525
+ animFrameId = null;
526
+ const kineticEnergy = step();
527
+ nbCache.clear();
528
+
529
+ ctx.clearRect(0, 0, width, height);
530
+ ctx.save();
531
+ ctx.translate(cam.tx, cam.ty);
532
+ ctx.scale(cam.scale, cam.scale);
533
+
534
+ const active = hovered || selected;
535
+ const neighborSet = active ? neighborIds(active) : null;
536
+
537
+ graph.edges.forEach(edge => {
538
+ if (!edge.source || !edge.target) return;
539
+ const style = edgeStyle(edge.type);
540
+ const isNeighborEdge = neighborSet && neighborSet.has(edge.from) && neighborSet.has(edge.to);
541
+ const baseAlpha = neighborSet ? (isNeighborEdge ? 0.88 : 0.07) : 0.34;
542
+ const widthBoost = isNeighborEdge ? 0.5 : 0;
543
+ ctx.save();
544
+ ctx.globalAlpha = baseAlpha;
545
+ ctx.strokeStyle = style.color;
546
+ ctx.lineWidth = (style.width + Math.min(3.4, (edge.weight || 1) * 1.1) + widthBoost) / cam.scale;
547
+ ctx.setLineDash(style.dash || []);
548
+ ctx.beginPath();
549
+ ctx.moveTo(edge.source.x, edge.source.y);
550
+ ctx.lineTo(edge.target.x, edge.target.y);
551
+ ctx.stroke();
552
+ ctx.restore();
553
+ });
554
+
555
+ graph.nodes.forEach(node => {
556
+ const isNeighbor = neighborSet ? neighborSet.has(node.id) : true;
557
+ const isSearchHit = searchResultIds.has(node.id);
558
+ const isSelected = node === selected;
559
+ const isHovered = node === hovered;
560
+ const alpha = neighborSet ? (isNeighbor ? 1 : 0.12) : 1;
561
+ const radius = node.r + (isSelected ? 4 : isHovered ? 2 : isSearchHit ? 2.6 : 0);
562
+
563
+ ctx.globalAlpha = alpha;
564
+
565
+ if (node.type === 'Topic') {
566
+ const haloRadius = radius + 6 + node.importance_norm * 8;
567
+ const halo = ctx.createRadialGradient(node.x, node.y, radius * 0.4, node.x, node.y, haloRadius);
568
+ halo.addColorStop(0, `${nodeColor(node.type)}30`);
569
+ halo.addColorStop(1, `${nodeColor(node.type)}00`);
570
+ ctx.fillStyle = halo;
571
+ ctx.beginPath();
572
+ ctx.arc(node.x, node.y, haloRadius, 0, Math.PI * 2);
573
+ ctx.fill();
574
+ }
575
+
576
+ // 노드 원 그리기 (흰 테두리 + 색상 채우기)
577
+ ctx.fillStyle = nodeColor(node.type);
578
+ ctx.beginPath();
579
+ ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
580
+ ctx.fill();
581
+
582
+ // 흰 테두리
583
+ ctx.strokeStyle = isSelected ? '#6f42e8' : 'rgba(255,255,255,0.85)';
584
+ ctx.lineWidth = (isSelected ? 3.2 : isHovered ? 2.4 : 2.0) / cam.scale;
585
+ ctx.beginPath();
586
+ ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
587
+ ctx.stroke();
588
+
589
+ // 선택/호버 외곽 링
590
+ if (isSelected || isHovered || isSearchHit) {
591
+ ctx.strokeStyle = isSelected ? '#6f42e8' : nodeColor(node.type);
592
+ ctx.lineWidth = (isSelected ? 2.8 : 1.8) / cam.scale;
593
+ ctx.globalAlpha = alpha * 0.55;
594
+ ctx.beginPath();
595
+ ctx.arc(node.x, node.y, radius + 5 / cam.scale, 0, Math.PI * 2);
596
+ ctx.stroke();
597
+ ctx.globalAlpha = alpha;
598
+ }
599
+
600
+ // 레이블 항상 노드 아래에 표시
601
+ {
602
+ const label = node.title.slice(0, 24);
603
+ const fs = Math.max(9.5, 12 / cam.scale);
604
+ ctx.font = `600 ${fs}px "SF Pro Display","Inter",system-ui`;
605
+ const lw = ctx.measureText(label).width;
606
+ const gap = (radius + 8) / cam.scale;
607
+ const lx = node.x - lw / 2;
608
+ const ly = node.y + gap + fs;
609
+ const pad = 4 / cam.scale;
610
+ const br = 5 / cam.scale;
611
+ // 흰 배경 pill
612
+ ctx.fillStyle = alpha > 0.5 ? 'rgba(255,255,255,0.88)' : 'rgba(255,255,255,0.22)';
613
+ ctx.beginPath();
614
+ if (ctx.roundRect) {
615
+ ctx.roundRect(lx - pad, ly - fs, lw + pad * 2, fs + pad * 1.6, br);
616
+ } else {
617
+ ctx.rect(lx - pad, ly - fs, lw + pad * 2, fs + pad * 1.6);
618
+ }
619
+ ctx.fill();
620
+ ctx.fillStyle = alpha > 0.5 ? '#14162c' : 'rgba(20,22,44,0.3)';
621
+ ctx.fillText(label, lx, ly);
622
+ }
623
+
624
+ ctx.globalAlpha = 1;
625
+ });
626
+
627
+ ctx.restore();
628
+ if (kineticEnergy > 0.04 || dragging) animFrameId = requestAnimationFrame(draw);
629
+ }
630
+
631
+ function toWorld(canvasX, canvasY) {
632
+ return { x: (canvasX - cam.tx) / cam.scale, y: (canvasY - cam.ty) / cam.scale };
633
+ }
634
+
635
+ function nodeAt(canvasX, canvasY) {
636
+ const { x, y } = toWorld(canvasX, canvasY);
637
+ let best = null;
638
+ let bestDistance = Infinity;
639
+ graph.nodes.forEach(node => {
640
+ const distance = Math.hypot(node.x - x, node.y - y);
641
+ if (distance < (node.r + 10) / cam.scale && distance < bestDistance) {
642
+ best = node;
643
+ bestDistance = distance;
644
+ }
645
+ });
646
+ return best;
647
+ }
648
+
649
+ function fitToScreen() {
650
+ if (!graph.nodes.length) return;
651
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
652
+ graph.nodes.forEach(node => {
653
+ x0 = Math.min(x0, node.x - node.r);
654
+ x1 = Math.max(x1, node.x + node.r);
655
+ y0 = Math.min(y0, node.y - node.r);
656
+ y1 = Math.max(y1, node.y + node.r);
657
+ });
658
+ const margin = 56;
659
+ const scale = Math.min(
660
+ 2.8,
661
+ Math.min(
662
+ (width - margin * 2) / Math.max(1, x1 - x0),
663
+ (height - margin * 2) / Math.max(1, y1 - y0)
664
+ )
665
+ );
666
+ cam.scale = scale;
667
+ cam.tx = (width - (x0 + x1) * scale) / 2;
668
+ cam.ty = (height - (y0 + y1) * scale) / 2;
669
+ wakeUp();
670
+ }
671
+
672
+ function centerOnNode(node, targetScale = cam.scale) {
673
+ cam.scale = clamp(targetScale, 0.12, 4.5);
674
+ cam.tx = width / 2 - node.x * cam.scale;
675
+ cam.ty = height / 2 - node.y * cam.scale;
676
+ wakeUp();
677
+ }
678
+
679
+ function metricCards(node) {
680
+ const metrics = ((node.metadata || {}).graph_metrics) || {};
681
+ const cards = [
682
+ { value: formatMetric(metrics.importance_norm ? metrics.importance_norm * 100 : 0, 0), label: 'Importance %' },
683
+ { value: formatMetric(metrics.degree || node.degree || 0, 0), label: 'Connections' },
684
+ ];
685
+ if (node.type === 'Topic') {
686
+ cards.push({ value: formatMetric(metrics.mention_count || 0, 0), label: 'Mentions' });
687
+ cards.push({ value: formatMetric(metrics.conversation_count || 0, 0), label: 'Conversations' });
688
+ } else {
689
+ cards.push({ value: formatMetric(metrics.recency_score || 0), label: 'Recency' });
690
+ cards.push({ value: formatMetric(node.importance || metrics.importance_raw || 0), label: 'Raw score' });
691
+ }
692
+ return `<div class="metric-grid">${cards.map(card => `
693
+ <div class="metric-card">
694
+ <strong>${escapeHtml(card.value)}</strong>
695
+ <span>${escapeHtml(card.label)}</span>
696
+ </div>
697
+ `).join('')}</div>`;
698
+ }
699
+
700
+ function showDetail(node) {
701
+ if (!node) {
702
+ selected = null;
703
+ detail.innerHTML = `<p class="empty-hint">${t('detail_empty_short')}</p>`;
704
+ wakeUp();
705
+ return;
706
+ }
707
+ selected = node;
708
+ const meta = node.metadata || {};
709
+ const convId = meta.conversation_id;
710
+ const jumpHtml = convId
711
+ ? `<a class="jump-btn" href="${API_BASE}/chat?open_conversation=${encodeURIComponent(convId)}">${t('open_in_chat')}</a>`
712
+ : '';
713
+ const metrics = metricCards(node);
714
+ const updatedAt = formatUpdatedAt(node.updated_at);
715
+ const source = meta.filename || meta.conversation_id || meta.source || '';
716
+ const metadataStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
717
+ detail.innerHTML = `
718
+ <div class="type-badge" style="background:${nodeColor(node.type)}">${escapeHtml(typeLabel(node.type))}</div>
719
+ <div class="detail-title">${escapeHtml(node.title || node.id)}</div>
720
+ ${node.summary ? `<div class="detail-summary">${escapeHtml(node.summary)}</div>` : ''}
721
+ ${jumpHtml}
722
+ ${metrics}
723
+ <div class="detail-summary">
724
+ ${source ? `<strong>source:</strong> ${escapeHtml(source)}<br>` : ''}
725
+ ${updatedAt ? `<strong>updated:</strong> ${escapeHtml(updatedAt)}` : ''}
726
+ </div>
727
+ ${metadataStr ? `<div class="meta-block">${escapeHtml(metadataStr)}</div>` : ''}
728
+ `;
729
+ wakeUp();
730
+ }
731
+
732
+ function resize() {
733
+ const rect = canvas.getBoundingClientRect();
734
+ width = rect.width;
735
+ height = rect.height;
736
+ const dpr = window.devicePixelRatio || 1;
737
+ canvas.width = Math.floor(width * dpr);
738
+ canvas.height = Math.floor(height * dpr);
739
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
740
+ }
741
+
742
+ function setSearchIdleState(message = t('ready')) {
743
+ searchCountEl.textContent = message;
744
+ }
745
+
746
+ function renderSearchResults() {
747
+ if (!searchInput.value.trim()) {
748
+ searchResultsEl.innerHTML = `<p class="search-empty">${t('search_empty')}</p>`;
749
+ return;
750
+ }
751
+ if (!searchResults.length) {
752
+ searchResultsEl.innerHTML = `<p class="search-empty">${t('search_no_results')}</p>`;
753
+ return;
754
+ }
755
+ searchResultsEl.innerHTML = `
756
+ <div class="search-list">
757
+ ${searchResults.map(match => {
758
+ const active = selected && selected.id === match.id ? 'active' : '';
759
+ const source = (match.metadata || {}).filename || (match.metadata || {}).conversation_id || '';
760
+ return `
761
+ <button class="search-item ${active}" data-node-id="${escapeHtml(match.id)}">
762
+ <div class="search-item-top">
763
+ <span class="search-type" style="background:${nodeColor(match.type)}">${escapeHtml(typeLabel(match.type))}</span>
764
+ <span class="search-item-title">${escapeHtml(match.title || match.id)}</span>
765
+ </div>
766
+ ${match.summary ? `<p class="search-item-summary">${escapeHtml(match.summary)}</p>` : ''}
767
+ <div class="search-item-meta">
768
+ ${source ? `<span>${escapeHtml(source)}</span>` : ''}
769
+ ${match.updated_at ? `<span>${escapeHtml(formatUpdatedAt(match.updated_at))}</span>` : ''}
770
+ </div>
771
+ </button>
772
+ `;
773
+ }).join('')}
774
+ </div>
775
+ `;
776
+ }
777
+
778
+ async function runSearch(query) {
779
+ const trimmed = String(query || '').trim();
780
+ if (!trimmed) {
781
+ searchResults = [];
782
+ searchResultIds = new Set();
783
+ setSearchIdleState(t('ready'));
784
+ renderSearchResults();
785
+ wakeUp();
786
+ return;
787
+ }
788
+
789
+ if (searchAbortController) searchAbortController.abort();
790
+ searchAbortController = new AbortController();
791
+ searchCountEl.textContent = t('searching');
792
+ searchResultsEl.innerHTML = `<p class="search-loading">${t('search_loading')}</p>`;
793
+
794
+ try {
795
+ const res = await apiFetch(`/knowledge-graph/search?q=${encodeURIComponent(trimmed)}&limit=12`, {
796
+ signal: searchAbortController.signal,
797
+ });
798
+ if (!res.ok) throw new Error(`Search failed (${res.status})`);
799
+ const data = await res.json();
800
+ searchResults = Array.isArray(data.matches) ? data.matches : [];
801
+ searchResultIds = new Set(searchResults.map(match => match.id));
802
+ searchCountEl.textContent = t('search_results').replace('{n}', searchResults.length);
803
+ renderSearchResults();
804
+ wakeUp();
805
+ } catch (error) {
806
+ if (error.name === 'AbortError') return;
807
+ searchResults = [];
808
+ searchResultIds = new Set();
809
+ searchCountEl.textContent = t('error');
810
+ searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
811
+ wakeUp();
812
+ }
813
+ }
814
+
815
+ function scheduleSearch() {
816
+ clearTimeout(searchDebounceId);
817
+ searchDebounceId = setTimeout(() => runSearch(searchInput.value), 160);
818
+ }
819
+
820
+ function clearSearch() {
821
+ searchInput.value = '';
822
+ searchResults = [];
823
+ searchResultIds = new Set();
824
+ setSearchIdleState(t('ready'));
825
+ renderSearchResults();
826
+ wakeUp();
827
+ }
828
+
829
+ async function focusSearchResult(match) {
830
+ let node = rawGraph.nodes.find(item => item.id === match.id);
831
+ if (!node) {
832
+ const res = await apiFetch(`/knowledge-graph/neighbors/${encodeURIComponent(match.id)}`);
833
+ if (res.ok) {
834
+ const payload = await res.json();
835
+ mergeGraphData([
836
+ {
837
+ id: match.id,
838
+ type: match.type,
839
+ title: match.title,
840
+ summary: match.summary,
841
+ metadata: match.metadata,
842
+ updated_at: match.updated_at,
843
+ },
844
+ ...((payload.neighbors || []).map(nodeItem => ({
845
+ ...nodeItem,
846
+ updated_at: nodeItem.updated_at,
847
+ }))),
848
+ ], payload.edges || []);
849
+ node = rawGraph.nodes.find(item => item.id === match.id);
850
+ }
851
+ }
852
+ if (!node) return;
853
+ showDetail(node);
854
+ centerOnNode(node, Math.max(cam.scale, node.type === 'Topic' ? 1.15 : 0.95));
855
+ renderSearchResults();
856
+ }
857
+
858
+ canvas.addEventListener('mousedown', event => {
859
+ const rect = canvas.getBoundingClientRect();
860
+ const canvasX = event.clientX - rect.left;
861
+ const canvasY = event.clientY - rect.top;
862
+ const node = nodeAt(canvasX, canvasY);
863
+ if (node) {
864
+ dragging = node;
865
+ showDetail(node);
866
+ } else {
867
+ panning = { sx: event.clientX, sy: event.clientY, tx0: cam.tx, ty0: cam.ty };
868
+ canvas.classList.add('panning');
869
+ }
870
+ wakeUp();
871
+ });
872
+
873
+ canvas.addEventListener('mousemove', event => {
874
+ const rect = canvas.getBoundingClientRect();
875
+ const node = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
876
+ if (node !== hovered) {
877
+ hovered = node;
878
+ wakeUp();
879
+ }
880
+ canvas.style.cursor = panning ? 'grabbing' : (node ? 'pointer' : 'grab');
881
+ if (node) {
882
+ const metrics = ((node.metadata || {}).graph_metrics) || {};
883
+ tooltip.style.display = 'block';
884
+ tooltip.style.left = `${event.clientX + 14}px`;
885
+ tooltip.style.top = `${event.clientY - 8}px`;
886
+ tooltip.innerHTML = `
887
+ <strong>${escapeHtml(node.title)}</strong><br>
888
+ ${escapeHtml(typeLabel(node.type))} · importance ${escapeHtml(formatMetric((node.importance_norm || 0) * 100, 0))}%<br>
889
+ ${node.type === 'Topic'
890
+ ? `mentions ${escapeHtml(formatMetric(metrics.mention_count || 0, 0))} · conversations ${escapeHtml(formatMetric(metrics.conversation_count || 0, 0))}`
891
+ : `connections ${escapeHtml(formatMetric(metrics.degree || node.degree || 0, 0))}`
892
+ }
893
+ `;
894
+ } else {
895
+ tooltip.style.display = 'none';
896
+ }
897
+ });
898
+
899
+ canvas.addEventListener('mouseleave', () => {
900
+ hovered = null;
901
+ tooltip.style.display = 'none';
902
+ wakeUp();
903
+ });
904
+
905
+ window.addEventListener('mousemove', event => {
906
+ if (dragging) {
907
+ const rect = canvas.getBoundingClientRect();
908
+ const world = toWorld(event.clientX - rect.left, event.clientY - rect.top);
909
+ dragging.x = world.x;
910
+ dragging.y = world.y;
911
+ dragging.vx = 0;
912
+ dragging.vy = 0;
913
+ wakeUp();
914
+ } else if (panning) {
915
+ cam.tx = panning.tx0 + (event.clientX - panning.sx);
916
+ cam.ty = panning.ty0 + (event.clientY - panning.sy);
917
+ wakeUp();
918
+ }
919
+ });
920
+
921
+ window.addEventListener('mouseup', () => {
922
+ dragging = null;
923
+ panning = null;
924
+ canvas.classList.remove('panning');
925
+ });
926
+
927
+ canvas.addEventListener('wheel', event => {
928
+ event.preventDefault();
929
+ const rect = canvas.getBoundingClientRect();
930
+ const canvasX = event.clientX - rect.left;
931
+ const canvasY = event.clientY - rect.top;
932
+ const zoomFactor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
933
+ const nextScale = clamp(cam.scale * zoomFactor, 0.07, 6);
934
+ cam.tx = canvasX - (canvasX - cam.tx) * (nextScale / cam.scale);
935
+ cam.ty = canvasY - (canvasY - cam.ty) * (nextScale / cam.scale);
936
+ cam.scale = nextScale;
937
+ wakeUp();
938
+ }, { passive: false });
939
+
940
+ let lastTouchDistance = null;
941
+ canvas.addEventListener('touchstart', event => {
942
+ event.preventDefault();
943
+ if (event.touches.length === 2) {
944
+ lastTouchDistance = Math.hypot(
945
+ event.touches[0].clientX - event.touches[1].clientX,
946
+ event.touches[0].clientY - event.touches[1].clientY
947
+ );
948
+ dragging = null;
949
+ return;
950
+ }
951
+ const touch = event.touches[0];
952
+ const rect = canvas.getBoundingClientRect();
953
+ const node = nodeAt(touch.clientX - rect.left, touch.clientY - rect.top);
954
+ if (node) {
955
+ dragging = node;
956
+ showDetail(node);
957
+ } else {
958
+ panning = { sx: touch.clientX, sy: touch.clientY, tx0: cam.tx, ty0: cam.ty };
959
+ }
960
+ wakeUp();
961
+ }, { passive: false });
962
+
963
+ canvas.addEventListener('touchmove', event => {
964
+ event.preventDefault();
965
+ if (event.touches.length === 2) {
966
+ const distance = Math.hypot(
967
+ event.touches[0].clientX - event.touches[1].clientX,
968
+ event.touches[0].clientY - event.touches[1].clientY
969
+ );
970
+ if (lastTouchDistance) {
971
+ const factor = distance / lastTouchDistance;
972
+ const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
973
+ const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
974
+ const rect = canvas.getBoundingClientRect();
975
+ const px = centerX - rect.left;
976
+ const py = centerY - rect.top;
977
+ const nextScale = clamp(cam.scale * factor, 0.07, 6);
978
+ cam.tx = px - (px - cam.tx) * (nextScale / cam.scale);
979
+ cam.ty = py - (py - cam.ty) * (nextScale / cam.scale);
980
+ cam.scale = nextScale;
981
+ wakeUp();
982
+ }
983
+ lastTouchDistance = distance;
984
+ return;
985
+ }
986
+
987
+ const touch = event.touches[0];
988
+ if (dragging) {
989
+ const rect = canvas.getBoundingClientRect();
990
+ const world = toWorld(touch.clientX - rect.left, touch.clientY - rect.top);
991
+ dragging.x = world.x;
992
+ dragging.y = world.y;
993
+ dragging.vx = 0;
994
+ dragging.vy = 0;
995
+ } else if (panning) {
996
+ cam.tx = panning.tx0 + (touch.clientX - panning.sx);
997
+ cam.ty = panning.ty0 + (touch.clientY - panning.sy);
998
+ }
999
+ wakeUp();
1000
+ }, { passive: false });
1001
+
1002
+ canvas.addEventListener('touchend', () => {
1003
+ dragging = null;
1004
+ panning = null;
1005
+ lastTouchDistance = null;
1006
+ });
1007
+
1008
+ searchInput.addEventListener('input', scheduleSearch);
1009
+ searchInput.addEventListener('keydown', event => {
1010
+ if (event.key === 'Enter' && searchResults.length) {
1011
+ event.preventDefault();
1012
+ focusSearchResult(searchResults[0]).catch(error => {
1013
+ searchCountEl.textContent = t('error');
1014
+ searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
1015
+ });
1016
+ }
1017
+ });
1018
+
1019
+ document.getElementById('clear-search-btn').addEventListener('click', clearSearch);
1020
+ document.addEventListener('click', event => {
1021
+ if (!event.target.closest('.lang-picker')) {
1022
+ document.querySelectorAll('.lang-picker-menu').forEach(menu => menu.classList.remove('open'));
1023
+ }
1024
+ });
1025
+ document.getElementById('refresh-btn').addEventListener('click', () => {
1026
+ rawGraph = { nodes: [], edges: [] };
1027
+ graph = { nodes: [], edges: [] };
1028
+ selected = null;
1029
+ loadGraph().catch(error => {
1030
+ detail.innerHTML = `<div class="type-badge" style="background:${nodeColor('ClearEvent')}; color:#091019">${t('error')}</div><div class="detail-title">${t('graph_refresh_fail')}</div><div class="detail-summary">${escapeHtml(error.message)}</div>`;
1031
+ });
1032
+ });
1033
+
1034
+ searchResultsEl.addEventListener('click', event => {
1035
+ const target = event.target.closest('[data-node-id]');
1036
+ if (!target) return;
1037
+ const match = searchResults.find(item => item.id === target.dataset.nodeId);
1038
+ if (!match) return;
1039
+ focusSearchResult(match).catch(error => {
1040
+ searchCountEl.textContent = t('error');
1041
+ searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
1042
+ });
1043
+ });
1044
+
1045
+ window.addEventListener('resize', () => {
1046
+ resize();
1047
+ wakeUp();
1048
+ });
1049
+
1050
+ resize();
1051
+ applyI18n();
1052
+ renderSearchResults();
1053
+ loadGraph().catch(error => {
1054
+ detail.innerHTML = `
1055
+ <div class="type-badge" style="background:${nodeColor('ClearEvent')}">${t('error')}</div>
1056
+ <div class="detail-title">${t('graph_load_fail')}</div>
1057
+ <div class="detail-summary">${escapeHtml(error.message)}</div>
1058
+ `;
1059
+ });