ltcai 4.0.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -33
- package/docs/CHANGELOG.md +64 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +86 -43
- package/docs/kg-schema.md +6 -2
- package/docs/spec-vs-impl.md +10 -10
- package/kg_schema.py +2 -603
- package/knowledge_graph.py +37 -4958
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +15 -16
- package/latticeai/api/agents.py +13 -6
- package/latticeai/api/auth.py +19 -11
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +4 -11
- package/latticeai/api/plugins.py +3 -6
- package/latticeai/api/realtime.py +4 -7
- package/latticeai/api/static_routes.py +9 -12
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +39 -6
- package/latticeai/api/workspace.py +24 -10
- package/latticeai/app_factory.py +88 -17
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/ingest.py +644 -0
- package/latticeai/brain/projection.py +561 -0
- package/latticeai/brain/provenance.py +401 -0
- package/latticeai/brain/retrieval.py +1316 -0
- package/latticeai/brain/schema.py +640 -0
- package/latticeai/brain/store.py +216 -0
- package/latticeai/brain/write_master.py +225 -0
- package/latticeai/core/invitations.py +131 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/sessions.py +31 -5
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workspace_os.py +420 -20
- package/latticeai/services/agent_runtime.py +242 -4
- package/latticeai/services/run_executor.py +328 -0
- package/latticeai/services/workspace_service.py +27 -19
- package/package.json +2 -14
- package/scripts/lint_v3.mjs +23 -0
- package/static/v3/asset-manifest.json +21 -14
- package/static/v3/js/{app.356e6452.js → app.c5c80c46.js} +1 -1
- package/static/v3/js/core/{api.7a308b89.js → api.ba0fbf14.js} +58 -1
- package/static/v3/js/core/api.js +57 -0
- package/static/v3/js/core/i18n.880e1fec.js +575 -0
- package/static/v3/js/core/i18n.js +575 -0
- package/static/v3/js/core/routes.37522821.js +101 -0
- package/static/v3/js/core/routes.js +71 -63
- package/static/v3/js/core/{shell.a1657f20.js → shell.e3f6bbfa.js} +67 -38
- package/static/v3/js/core/shell.js +65 -36
- package/static/v3/js/core/{store.204a08b2.js → store.7b2aa044.js} +10 -0
- package/static/v3/js/core/store.js +10 -0
- package/static/v3/js/views/account.eff40715.js +143 -0
- package/static/v3/js/views/account.js +143 -0
- package/static/v3/js/views/activity.0d271ef9.js +67 -0
- package/static/v3/js/views/activity.js +67 -0
- package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
- package/static/v3/js/views/admin-users.js +4 -6
- package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
- package/static/v3/js/views/agents.js +35 -12
- package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
- package/static/v3/js/views/chat.js +23 -0
- package/static/v3/js/views/{knowledge-graph.5e40cbeb.js → knowledge-graph.4d09c537.js} +27 -7
- package/static/v3/js/views/knowledge-graph.js +27 -7
- package/static/v3/js/views/network.52a4f181.js +97 -0
- package/static/v3/js/views/network.js +97 -0
- package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
- package/static/v3/js/views/planning.js +26 -5
- package/static/v3/js/views/runs.b63b2afa.js +144 -0
- package/static/v3/js/views/runs.js +144 -0
- package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
- package/static/v3/js/views/settings.js +7 -8
- package/static/v3/js/views/snapshots.6f5db095.js +135 -0
- package/static/v3/js/views/snapshots.js +135 -0
- package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
- package/static/v3/js/views/workflows.js +87 -2
- package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
- package/static/v3/js/views/workspace-admin.js +156 -0
- package/static/account.html +0 -113
- package/static/activity.html +0 -73
- package/static/admin.html +0 -486
- package/static/agents.html +0 -139
- package/static/chat.html +0 -841
- package/static/css/reference/account.css +0 -439
- package/static/css/reference/admin.css +0 -610
- package/static/css/reference/base.css +0 -1661
- package/static/css/reference/chat.css +0 -4623
- package/static/css/reference/graph.css +0 -1016
- package/static/css/responsive.css +0 -861
- package/static/graph.html +0 -122
- package/static/platform.css +0 -104
- package/static/plugins.html +0 -136
- package/static/scripts/account.js +0 -238
- package/static/scripts/admin.js +0 -1614
- package/static/scripts/chat.js +0 -5081
- package/static/scripts/graph.js +0 -1804
- package/static/scripts/platform.js +0 -64
- package/static/scripts/ux.js +0 -167
- package/static/scripts/workspace.js +0 -948
- package/static/v3/js/core/routes.7222343d.js +0 -93
- package/static/workflows.html +0 -146
- package/static/workspace.css +0 -1121
- package/static/workspace.html +0 -357
package/static/scripts/graph.js
DELETED
|
@@ -1,1804 +0,0 @@
|
|
|
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_workspace: 'Workspace OS', 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
|
-
local_sources: '지식 소스', local_notice: 'Lattice AI는 사용자가 선택한 폴더만 AI 지식으로 변환합니다.',
|
|
18
|
-
local_path_ph: '폴더 경로 입력...', local_roots: '드라이브 선택', local_tree: '폴더 구조 확인',
|
|
19
|
-
local_audit: '안전 검사', local_index: '지식 그래프 만들기', local_ocr: '이미지 글자 인식',
|
|
20
|
-
local_watch: '자동 감지 켜기', local_permission: '권한 승인', local_sources_empty: '아직 추가된 지식 소스가 없습니다.',
|
|
21
|
-
local_indexed: '지식 그래프 생성 완료', local_watch_unavailable: '자동 감지는 watchdog 설치 후 작동합니다.',
|
|
22
|
-
detail_empty: '노드를 클릭하면 요약, 중요도, 연결 강도, 메타데이터를 볼 수 있습니다. 검색 패널에서는 서버 검색 결과를 기준으로 더 정확하게 이동할 수 있습니다.',
|
|
23
|
-
detail_empty_short: '노드를 클릭하면 요약, 중요도, 메타데이터를 볼 수 있습니다.',
|
|
24
|
-
refresh: '새로고침', fit: '맞춤', expand: '확장', collapse: '접기', focus: '포커스', path: '경로',
|
|
25
|
-
clear_focus: '포커스 해제', path_start: '경로 시작 지정', path_ready: '경로 시작: {title}', path_pick_target: '도착 노드를 선택한 뒤 경로를 누르세요.',
|
|
26
|
-
path_not_found: '두 노드 사이의 경로를 찾지 못했습니다.', source_open: '소스 열기',
|
|
27
|
-
error: '오류', graph_load_fail: '그래프를 불러오지 못했습니다.', graph_refresh_fail: '그래프를 새로고침하지 못했습니다.',
|
|
28
|
-
no_node_types: '아직 노드 유형이 없습니다.', no_relationships: '아직 관계가 없습니다.',
|
|
29
|
-
open_in_chat: '채팅에서 열기', today: '오늘', day_ago: '1일 전', days_ago: '{n}일 전', months_ago: '{n}개월 전', years_ago: '{n}년 전',
|
|
30
|
-
},
|
|
31
|
-
en: {
|
|
32
|
-
nav_home: 'Home', nav_workspace: 'Workspace OS', nav_graph: 'Knowledge Graph', nav_chat: 'Chat', nav_files: 'Files', nav_code: 'Code', nav_settings: 'Settings',
|
|
33
|
-
project: 'Project', search_title: 'Explore the graph', search_sub: 'Search topics, files, conversations, decisions, and tasks.',
|
|
34
|
-
ready: 'Ready', search_ph: 'Search by topic, file, or conversation...', clear_search: 'Clear search',
|
|
35
|
-
search_results: '{n} result(s)',
|
|
36
|
-
search_empty: 'Search results appear here. Enter a keyword to load server results and jump directly to a node.',
|
|
37
|
-
search_no_results: 'No matching nodes found. Try a more specific topic, filename, or conversation title.',
|
|
38
|
-
searching: 'Searching...', search_loading: 'Searching graph index...',
|
|
39
|
-
sidebar_eyebrow: 'Knowledge Graph', sidebar_title: 'Knowledge topology',
|
|
40
|
-
sidebar_sub: 'Topic size follows importance; line width and color reflect relationship type and strength.',
|
|
41
|
-
nodes: 'Nodes', edges: 'Edges', relationship_legend: 'Relationship legend', node_types: 'Node types',
|
|
42
|
-
local_sources: 'Knowledge sources', local_notice: 'Lattice AI only turns folders you choose into AI knowledge.',
|
|
43
|
-
local_path_ph: 'Enter a folder path...', local_roots: 'Drive picker', local_tree: 'Check folders',
|
|
44
|
-
local_audit: 'Safety check', local_index: 'Build graph', local_ocr: 'Image text recognition',
|
|
45
|
-
local_watch: 'Auto watch', local_permission: 'Approve access', local_sources_empty: 'No knowledge sources yet.',
|
|
46
|
-
local_indexed: 'Knowledge graph built', local_watch_unavailable: 'Auto watch works after watchdog is installed.',
|
|
47
|
-
detail_empty: 'Click a node to see its summary, importance, connection strength, and metadata. Search results can jump to more precise nodes.',
|
|
48
|
-
detail_empty_short: 'Click a node to see its summary, importance, and metadata.',
|
|
49
|
-
refresh: 'Refresh', fit: 'Fit', expand: 'Expand', collapse: 'Collapse', focus: 'Focus', path: 'Path',
|
|
50
|
-
clear_focus: 'Clear focus', path_start: 'Set path start', path_ready: 'Path start: {title}', path_pick_target: 'Select a destination node, then press Path.',
|
|
51
|
-
path_not_found: 'No path found between those nodes.', source_open: 'Open source',
|
|
52
|
-
error: 'Error', graph_load_fail: 'Could not load the graph.', graph_refresh_fail: 'Could not refresh the graph.',
|
|
53
|
-
no_node_types: 'No node types yet.', no_relationships: 'No relationships yet.',
|
|
54
|
-
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',
|
|
55
|
-
}
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
let currentLang = localStorage.getItem('ltcai_lang') || 'ko';
|
|
59
|
-
function t(key) { return (G18N[currentLang] || G18N.ko)[key] || key; }
|
|
60
|
-
|
|
61
|
-
function applyI18n() {
|
|
62
|
-
document.documentElement.lang = currentLang;
|
|
63
|
-
const navLabels = ['nav_home', 'nav_workspace', 'nav_graph', 'nav_chat', 'nav_files', 'nav_code', 'nav_settings'];
|
|
64
|
-
document.querySelectorAll('.graph-rail nav a').forEach((link, index) => {
|
|
65
|
-
const icon = link.querySelector('i')?.outerHTML || '';
|
|
66
|
-
link.innerHTML = `${icon} ${t(navLabels[index])}`;
|
|
67
|
-
});
|
|
68
|
-
const projectLabel = document.querySelector('.rail-project span');
|
|
69
|
-
if (projectLabel) projectLabel.textContent = t('project');
|
|
70
|
-
document.querySelector('.search-title strong').textContent = t('search_title');
|
|
71
|
-
document.querySelector('.search-title span').textContent = t('search_sub');
|
|
72
|
-
searchInput.placeholder = t('search_ph');
|
|
73
|
-
document.getElementById('clear-search-btn').title = t('clear_search');
|
|
74
|
-
document.querySelector('.eyebrow').textContent = t('sidebar_eyebrow');
|
|
75
|
-
document.querySelector('.sidebar-head h1').textContent = t('sidebar_title');
|
|
76
|
-
document.querySelector('.sidebar-sub').textContent = t('sidebar_sub');
|
|
77
|
-
document.querySelectorAll('.stat span')[0].textContent = t('nodes');
|
|
78
|
-
document.querySelectorAll('.stat span')[1].textContent = t('edges');
|
|
79
|
-
document.getElementById('local-source-label').textContent = t('local_sources');
|
|
80
|
-
document.getElementById('edge-label').textContent = t('relationship_legend');
|
|
81
|
-
document.getElementById('type-label').textContent = t('node_types');
|
|
82
|
-
const toolbarLabels = {
|
|
83
|
-
'refresh-btn': ['ti-refresh', t('refresh')],
|
|
84
|
-
'fit-btn': ['ti-arrows-maximize', t('fit')],
|
|
85
|
-
'expand-btn': ['ti-circle-plus', t('expand')],
|
|
86
|
-
'collapse-btn': ['ti-circle-minus', t('collapse')],
|
|
87
|
-
'focus-btn': ['ti-focus-2', focusNodeId ? t('clear_focus') : t('focus')],
|
|
88
|
-
'path-btn': ['ti-route', t('path')],
|
|
89
|
-
};
|
|
90
|
-
Object.entries(toolbarLabels).forEach(([id, [icon, label]]) => {
|
|
91
|
-
const btn = document.getElementById(id);
|
|
92
|
-
if (btn) btn.innerHTML = `<i class="ti ${icon}"></i> ${label}`;
|
|
93
|
-
});
|
|
94
|
-
const langBtn = document.getElementById('graph-lang-btn');
|
|
95
|
-
if (langBtn) langBtn.textContent = `Language: ${currentLang === 'ko' ? '한국어' : 'English'}`;
|
|
96
|
-
['ko', 'en'].forEach(lang => {
|
|
97
|
-
const el = document.getElementById(`graph-lang-${lang}`);
|
|
98
|
-
if (el) el.classList.toggle('active', lang === currentLang);
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function toggleLangMenu(pickerId) {
|
|
103
|
-
const menu = document.getElementById(`${pickerId}-menu`);
|
|
104
|
-
if (!menu) return;
|
|
105
|
-
const isOpen = menu.classList.contains('open');
|
|
106
|
-
document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
|
|
107
|
-
if (!isOpen) menu.classList.add('open');
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function setLang(lang) {
|
|
111
|
-
currentLang = lang;
|
|
112
|
-
localStorage.setItem('ltcai_lang', lang);
|
|
113
|
-
document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
|
|
114
|
-
applyI18n();
|
|
115
|
-
setSearchIdleState(searchInput.value.trim() ? searchCountEl.textContent : t('ready'));
|
|
116
|
-
renderSearchResults();
|
|
117
|
-
renderTypeFilters(buildTypeCounts());
|
|
118
|
-
renderEdgeLegend(buildEdgeCounts());
|
|
119
|
-
renderLocalSources();
|
|
120
|
-
showDetail(selected);
|
|
121
|
-
}
|
|
122
|
-
window.toggleLangMenu = toggleLangMenu;
|
|
123
|
-
window.setLang = setLang;
|
|
124
|
-
|
|
125
|
-
const TYPE_CONFIG = {
|
|
126
|
-
Computer: { color: '#14b8a6', label: 'Computer' },
|
|
127
|
-
Drive: { color: '#38bdf8', label: 'Drive' },
|
|
128
|
-
Folder: { color: '#f0a500', label: 'Folder' },
|
|
129
|
-
Conversation: { color: '#9b8af0', label: 'Conversation' },
|
|
130
|
-
Message: { color: '#b8a9f5', label: 'Message' },
|
|
131
|
-
AIResponse: { color: '#6f42e8', label: 'AI Response' },
|
|
132
|
-
File: { color: '#5b9cf6', label: 'File' },
|
|
133
|
-
Document: { color: '#5b9cf6', label: 'Document' },
|
|
134
|
-
CodeFile: { color: '#22c55e', label: 'Code File' },
|
|
135
|
-
Spreadsheet: { color: '#059669', label: 'Spreadsheet' },
|
|
136
|
-
SlideDeck: { color: '#818cf8', label: 'Slide Deck' },
|
|
137
|
-
Topic: { color: '#7c3aed', label: 'Topic' },
|
|
138
|
-
Concept: { color: '#7c3aed', label: 'Concept' },
|
|
139
|
-
Person: { color: '#0d9488', label: 'Person' },
|
|
140
|
-
Page: { color: '#a78bfa', label: 'Page' },
|
|
141
|
-
Slide: { color: '#818cf8', label: 'Slide' },
|
|
142
|
-
Sheet: { color: '#059669', label: 'Sheet' },
|
|
143
|
-
Image: { color: '#d97706', label: 'Image' },
|
|
144
|
-
ImageText: { color: '#f97316', label: 'Image Text' },
|
|
145
|
-
Decision: { color: '#f59e0b', label: 'Decision' },
|
|
146
|
-
Task: { color: '#ec4899', label: 'Task' },
|
|
147
|
-
ClearEvent: { color: '#6366f1', label: 'Clear Event' },
|
|
148
|
-
Event: { color: '#8b5cf6', label: 'Event' },
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const EDGE_CONFIG = {
|
|
152
|
-
contains: { color: '#7186c8', label: 'Contains', width: 1.3 },
|
|
153
|
-
authored: { color: '#20b8aa', label: 'Authored', width: 1.5 },
|
|
154
|
-
uploaded: { color: '#7db7ff', label: 'Uploaded', width: 1.5 },
|
|
155
|
-
has_event: { color: '#7a6ba8', label: 'Event', width: 1.2 },
|
|
156
|
-
triggered: { color: '#a77cff', label: 'Triggered', width: 1.2, dash: [5, 4] },
|
|
157
|
-
mentions: { color: '#aebcff', label: 'Mentions', width: 1.55 },
|
|
158
|
-
discusses: { color: '#c9b7ff', label: 'Discusses', width: 1.75 },
|
|
159
|
-
implies: { color: '#ff7db3', label: 'Implies', width: 1.55 },
|
|
160
|
-
based_on: { color: '#a77cff', label: 'Based on', width: 1.4, dash: [8, 4] },
|
|
161
|
-
contains_signal: { color: '#f1c86d', label: 'Signal', width: 1.6 },
|
|
162
|
-
has_page: { color: '#7186c8', label: 'Page', width: 1.25 },
|
|
163
|
-
has_slide: { color: '#8fa3ff', label: 'Slide', width: 1.3 },
|
|
164
|
-
has_sheet: { color: '#20b8aa', label: 'Sheet', width: 1.3 },
|
|
165
|
-
contains_image: { color: '#f1c86d', label: 'Image', width: 1.35 },
|
|
166
|
-
has_chunk: { color: '#4e566f', label: 'Chunk', width: 0.9, dash: [2, 5] },
|
|
167
|
-
'포함함': { color: '#7186c8', label: 'Contains', width: 1.35 },
|
|
168
|
-
'언급함': { color: '#aebcff', label: 'Mentions', width: 1.45 },
|
|
169
|
-
'관련됨': { color: '#7f8f9d', label: 'Related', width: 1.3 },
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const canvas = document.getElementById('graph');
|
|
173
|
-
const ctx = canvas.getContext('2d');
|
|
174
|
-
const detail = document.getElementById('detail');
|
|
175
|
-
const tooltip = document.getElementById('tooltip');
|
|
176
|
-
const searchInput = document.getElementById('search');
|
|
177
|
-
const searchResultsEl = document.getElementById('search-results');
|
|
178
|
-
const searchCountEl = document.getElementById('search-count');
|
|
179
|
-
const localSourcePanel = document.getElementById('local-source-panel');
|
|
180
|
-
|
|
181
|
-
let rawGraph = { nodes: [], edges: [] };
|
|
182
|
-
let graph = { nodes: [], edges: [] };
|
|
183
|
-
let hiddenTypes = new Set();
|
|
184
|
-
let hiddenEdgeTypes = new Set();
|
|
185
|
-
let selected = null;
|
|
186
|
-
let hovered = null;
|
|
187
|
-
let dragging = null;
|
|
188
|
-
let panning = null;
|
|
189
|
-
let cam = { scale: 1, tx: 0, ty: 0 };
|
|
190
|
-
let animFrameId = null;
|
|
191
|
-
let width = 0;
|
|
192
|
-
let height = 0;
|
|
193
|
-
let searchResults = [];
|
|
194
|
-
let searchResultIds = new Set();
|
|
195
|
-
let expandedNodeIds = new Set();
|
|
196
|
-
let hiddenNodeIds = new Set();
|
|
197
|
-
let focusNodeId = null;
|
|
198
|
-
let focusDepth = 2;
|
|
199
|
-
let pathStartId = null;
|
|
200
|
-
let pathNodeIds = new Set();
|
|
201
|
-
let pathEdgeKeys = new Set();
|
|
202
|
-
let searchAbortController = null;
|
|
203
|
-
let searchDebounceId = null;
|
|
204
|
-
let localState = {
|
|
205
|
-
roots: [],
|
|
206
|
-
sources: [],
|
|
207
|
-
watch: null,
|
|
208
|
-
selectedPath: '',
|
|
209
|
-
tree: null,
|
|
210
|
-
audit: null,
|
|
211
|
-
includeOcr: false,
|
|
212
|
-
watchEnabled: false,
|
|
213
|
-
busy: false,
|
|
214
|
-
status: '',
|
|
215
|
-
error: '',
|
|
216
|
-
pendingPermission: null,
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
function apiFetch(path, opts = {}) {
|
|
220
|
-
return fetch(`${API_BASE}${path}`, {
|
|
221
|
-
credentials: 'include',
|
|
222
|
-
...opts,
|
|
223
|
-
headers: { ...(opts.headers || {}) },
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function clamp(value, min, max) {
|
|
228
|
-
return Math.max(min, Math.min(max, value));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function escapeHtml(text) {
|
|
232
|
-
return String(text || '')
|
|
233
|
-
.replaceAll('&', '&')
|
|
234
|
-
.replaceAll('<', '<')
|
|
235
|
-
.replaceAll('>', '>')
|
|
236
|
-
.replaceAll('"', '"')
|
|
237
|
-
.replaceAll("'", ''');
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function formatCount(value) {
|
|
241
|
-
return Number(value || 0).toLocaleString();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
async function apiJson(path, payload) {
|
|
245
|
-
return apiFetch(path, {
|
|
246
|
-
method: 'POST',
|
|
247
|
-
headers: { 'Content-Type': 'application/json' },
|
|
248
|
-
body: JSON.stringify(payload || {}),
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
async function loadLocalSources() {
|
|
253
|
-
try {
|
|
254
|
-
const [rootsRes, sourcesRes] = await Promise.all([
|
|
255
|
-
apiFetch('/knowledge-graph/local/roots'),
|
|
256
|
-
apiFetch('/knowledge-graph/local/sources'),
|
|
257
|
-
]);
|
|
258
|
-
if (rootsRes.status === 401 || sourcesRes.status === 401) {
|
|
259
|
-
window.location.href = '/account';
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
const rootsData = rootsRes.ok ? await rootsRes.json() : {};
|
|
263
|
-
const sourcesData = sourcesRes.ok ? await sourcesRes.json() : {};
|
|
264
|
-
localState.roots = Array.isArray(rootsData.roots) ? rootsData.roots : [];
|
|
265
|
-
localState.sources = Array.isArray(sourcesData.sources) ? sourcesData.sources : [];
|
|
266
|
-
localState.watch = sourcesData.watch || null;
|
|
267
|
-
if (!localState.selectedPath && localState.roots[0]) {
|
|
268
|
-
localState.selectedPath = localState.roots[0].path;
|
|
269
|
-
}
|
|
270
|
-
renderLocalSources();
|
|
271
|
-
} catch (error) {
|
|
272
|
-
localState.error = error.message;
|
|
273
|
-
renderLocalSources();
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function renderLocalSources() {
|
|
278
|
-
if (!localSourcePanel) return;
|
|
279
|
-
const rootRows = localState.roots.slice(0, 8).map(root => {
|
|
280
|
-
const active = root.path === localState.selectedPath ? 'active' : '';
|
|
281
|
-
return `
|
|
282
|
-
<button class="local-root-btn ${active}" onclick="selectLocalPath(decodeURIComponent('${encodeURIComponent(root.path)}'))" title="${escapeHtml(root.path)}">
|
|
283
|
-
<i class="ti ${root.kind === 'drive' || root.kind === 'volume' ? 'ti-device-desktop' : 'ti-folder'}"></i>
|
|
284
|
-
<span class="local-source-main">
|
|
285
|
-
<strong>${escapeHtml(root.label || root.path)}</strong>
|
|
286
|
-
<span>${escapeHtml(root.path)}</span>
|
|
287
|
-
</span>
|
|
288
|
-
${root.warning ? '<i class="ti ti-alert-triangle"></i>' : ''}
|
|
289
|
-
</button>
|
|
290
|
-
`;
|
|
291
|
-
}).join('');
|
|
292
|
-
|
|
293
|
-
const treeRows = (localState.tree?.items || []).slice(0, 8).map(item => `
|
|
294
|
-
<div class="local-tree-row" title="${escapeHtml(item.path)}">
|
|
295
|
-
<i class="ti ${item.type === 'directory' ? 'ti-folder' : 'ti-file'}"></i>
|
|
296
|
-
<span class="local-tree-main">
|
|
297
|
-
<strong>${escapeHtml(item.name)}</strong>
|
|
298
|
-
<span>${escapeHtml(item.excluded_reason || item.extension || item.type)}</span>
|
|
299
|
-
</span>
|
|
300
|
-
${item.accessible === false ? '<i class="ti ti-lock"></i>' : ''}
|
|
301
|
-
</div>
|
|
302
|
-
`).join('');
|
|
303
|
-
|
|
304
|
-
const summary = localState.audit?.summary || null;
|
|
305
|
-
const auditHtml = summary ? `
|
|
306
|
-
<div class="local-audit-grid">
|
|
307
|
-
<div class="local-audit-stat"><strong>${formatCount(summary.readable_files)}</strong><span>읽을 파일</span></div>
|
|
308
|
-
<div class="local-audit-stat"><strong>${formatCount(summary.sensitive_files)}</strong><span>민감 제외</span></div>
|
|
309
|
-
<div class="local-audit-stat"><strong>${formatCount(summary.unsupported_files)}</strong><span>미지원</span></div>
|
|
310
|
-
<div class="local-audit-stat"><strong>${formatCount(summary.too_large_files)}</strong><span>너무 큼</span></div>
|
|
311
|
-
<div class="local-audit-stat"><strong>${formatCount(summary.image_ocr_candidates)}</strong><span>이미지</span></div>
|
|
312
|
-
<div class="local-audit-stat"><strong>${formatCount(summary.estimated_seconds)}</strong><span>예상 초</span></div>
|
|
313
|
-
</div>
|
|
314
|
-
` : '';
|
|
315
|
-
|
|
316
|
-
const permissionHtml = localState.pendingPermission ? `
|
|
317
|
-
<div class="local-permission">
|
|
318
|
-
<div class="local-status-line">${escapeHtml(localState.pendingPermission.message || '')}</div>
|
|
319
|
-
<button class="local-source-btn primary" onclick="approveLocalPermission()">
|
|
320
|
-
<i class="ti ti-shield-check"></i>${t('local_permission')}
|
|
321
|
-
</button>
|
|
322
|
-
</div>
|
|
323
|
-
` : '';
|
|
324
|
-
|
|
325
|
-
const sourceRows = localState.sources.slice(0, 4).map(source => {
|
|
326
|
-
const status = source.watch_active ? '자동 감지 중' : (source.watch_enabled ? '자동 감지 대기' : '수동 반영');
|
|
327
|
-
return `
|
|
328
|
-
<div class="local-source-row" title="${escapeHtml(source.root_path)}">
|
|
329
|
-
<i class="ti ti-database"></i>
|
|
330
|
-
<span class="local-source-main">
|
|
331
|
-
<strong>${escapeHtml(source.label || source.root_path)}</strong>
|
|
332
|
-
<span>${escapeHtml(status)} · ${escapeHtml(source.root_path)}</span>
|
|
333
|
-
</span>
|
|
334
|
-
<span>${formatCount((source.file_status || {}).indexed)}</span>
|
|
335
|
-
</div>
|
|
336
|
-
`;
|
|
337
|
-
}).join('');
|
|
338
|
-
|
|
339
|
-
const watchWarning = localState.watch && localState.watch.available === false
|
|
340
|
-
? `<div class="local-status-line">${t('local_watch_unavailable')}</div>`
|
|
341
|
-
: '';
|
|
342
|
-
const statusClass = localState.error ? ' error' : '';
|
|
343
|
-
const statusText = localState.error || localState.status || '';
|
|
344
|
-
|
|
345
|
-
localSourcePanel.innerHTML = `
|
|
346
|
-
<div class="local-source-notice">${t('local_notice')}</div>
|
|
347
|
-
<div class="local-source-input">
|
|
348
|
-
<input id="local-path-input" value="${escapeHtml(localState.selectedPath)}" placeholder="${t('local_path_ph')}" oninput="updateLocalPath(this.value)">
|
|
349
|
-
</div>
|
|
350
|
-
${rootRows ? `<div class="local-root-list">${rootRows}</div>` : ''}
|
|
351
|
-
<div class="local-option-row">
|
|
352
|
-
<button type="button" class="local-option-btn ${localState.includeOcr ? 'active' : ''}" onclick="setLocalOption('includeOcr', ${localState.includeOcr ? 'false' : 'true'})" aria-pressed="${localState.includeOcr ? 'true' : 'false'}">
|
|
353
|
-
<i class="ti ti-photo-scan"></i><span>${t('local_ocr')}</span>
|
|
354
|
-
</button>
|
|
355
|
-
<button type="button" class="local-option-btn ${localState.watchEnabled ? 'active' : ''}" onclick="setLocalOption('watchEnabled', ${localState.watchEnabled ? 'false' : 'true'})" aria-pressed="${localState.watchEnabled ? 'true' : 'false'}">
|
|
356
|
-
<i class="ti ti-refresh-dot"></i><span>${t('local_watch')}</span>
|
|
357
|
-
</button>
|
|
358
|
-
</div>
|
|
359
|
-
<div class="local-source-actions">
|
|
360
|
-
<button class="local-source-btn" ${localState.busy ? 'disabled' : ''} onclick="runLocalTree()" title="${t('local_tree')}"><i class="ti ti-folders"></i>${t('local_tree')}</button>
|
|
361
|
-
<button class="local-source-btn" ${localState.busy ? 'disabled' : ''} onclick="runLocalAudit()" title="${t('local_audit')}"><i class="ti ti-shield-search"></i>${t('local_audit')}</button>
|
|
362
|
-
<button class="local-source-btn primary" ${localState.busy ? 'disabled' : ''} onclick="runLocalIndex()" title="${t('local_index')}"><i class="ti ti-chart-dots-3"></i>${t('local_index')}</button>
|
|
363
|
-
</div>
|
|
364
|
-
${permissionHtml}
|
|
365
|
-
${statusText ? `<div class="local-status-line${statusClass}">${escapeHtml(statusText)}</div>` : ''}
|
|
366
|
-
${watchWarning}
|
|
367
|
-
${auditHtml}
|
|
368
|
-
${treeRows ? `<div class="local-tree-list">${treeRows}</div>` : ''}
|
|
369
|
-
<div class="local-source-list">
|
|
370
|
-
${sourceRows || `<div class="local-status-line">${t('local_sources_empty')}</div>`}
|
|
371
|
-
</div>
|
|
372
|
-
`;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function selectLocalPath(path) {
|
|
376
|
-
localState.selectedPath = path;
|
|
377
|
-
localState.tree = null;
|
|
378
|
-
localState.audit = null;
|
|
379
|
-
localState.error = '';
|
|
380
|
-
localState.status = '';
|
|
381
|
-
renderLocalSources();
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function updateLocalPath(path) {
|
|
385
|
-
localState.selectedPath = path;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function setLocalOption(key, value) {
|
|
389
|
-
localState[key] = Boolean(value);
|
|
390
|
-
renderLocalSources();
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
async function runLocalRequest(endpoint, payload, onSuccess) {
|
|
394
|
-
if (!localState.selectedPath) return;
|
|
395
|
-
localState.busy = true;
|
|
396
|
-
localState.error = '';
|
|
397
|
-
localState.status = '';
|
|
398
|
-
localState.pendingPermission = null;
|
|
399
|
-
renderLocalSources();
|
|
400
|
-
try {
|
|
401
|
-
const res = await apiJson(endpoint, payload);
|
|
402
|
-
if (res.status === 401) {
|
|
403
|
-
window.location.href = '/account';
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
const data = await res.json();
|
|
407
|
-
if (data.permission_required) {
|
|
408
|
-
localState.pendingPermission = { endpoint, payload, ...data };
|
|
409
|
-
localState.busy = false;
|
|
410
|
-
renderLocalSources();
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`);
|
|
414
|
-
await onSuccess(data);
|
|
415
|
-
} catch (error) {
|
|
416
|
-
localState.error = error.message;
|
|
417
|
-
} finally {
|
|
418
|
-
localState.busy = false;
|
|
419
|
-
renderLocalSources();
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
async function approveLocalPermission() {
|
|
424
|
-
const pending = localState.pendingPermission;
|
|
425
|
-
if (!pending) return;
|
|
426
|
-
localState.busy = true;
|
|
427
|
-
renderLocalSources();
|
|
428
|
-
try {
|
|
429
|
-
const approveRes = await apiFetch(`/permissions/approve/${encodeURIComponent(pending.approval_token)}`, { method: 'POST' });
|
|
430
|
-
const approveData = await approveRes.json().catch(() => ({}));
|
|
431
|
-
if (!approveRes.ok) throw new Error(approveData.detail || `Approval failed (${approveRes.status})`);
|
|
432
|
-
const payload = { ...pending.payload, approved: true, approval_token: pending.approval_token };
|
|
433
|
-
const res = await apiJson(pending.endpoint, payload);
|
|
434
|
-
const data = await res.json();
|
|
435
|
-
if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`);
|
|
436
|
-
localState.pendingPermission = null;
|
|
437
|
-
if (pending.endpoint.endsWith('/tree')) {
|
|
438
|
-
localState.tree = data;
|
|
439
|
-
localState.status = data.privacy_notice || '';
|
|
440
|
-
} else if (pending.endpoint.endsWith('/audit')) {
|
|
441
|
-
localState.audit = data;
|
|
442
|
-
localState.status = data.privacy_notice || '';
|
|
443
|
-
} else if (pending.endpoint.endsWith('/index')) {
|
|
444
|
-
localState.status = `${t('local_indexed')} · ${formatCount((data.counts || {}).indexed)} files`;
|
|
445
|
-
await Promise.all([loadGraph(), loadLocalSources()]);
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
} catch (error) {
|
|
449
|
-
localState.error = error.message;
|
|
450
|
-
} finally {
|
|
451
|
-
localState.busy = false;
|
|
452
|
-
renderLocalSources();
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function runLocalTree() {
|
|
457
|
-
runLocalRequest('/knowledge-graph/local/tree', {
|
|
458
|
-
path: localState.selectedPath,
|
|
459
|
-
max_items: 120,
|
|
460
|
-
}, data => {
|
|
461
|
-
localState.tree = data;
|
|
462
|
-
localState.status = data.privacy_notice || '';
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function runLocalAudit() {
|
|
467
|
-
runLocalRequest('/knowledge-graph/local/audit', {
|
|
468
|
-
path: localState.selectedPath,
|
|
469
|
-
include_ocr: localState.includeOcr,
|
|
470
|
-
max_files: 50000,
|
|
471
|
-
}, data => {
|
|
472
|
-
localState.audit = data;
|
|
473
|
-
localState.status = data.privacy_notice || '';
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function runLocalIndex() {
|
|
478
|
-
runLocalRequest('/knowledge-graph/local/index', {
|
|
479
|
-
path: localState.selectedPath,
|
|
480
|
-
include_ocr: localState.includeOcr,
|
|
481
|
-
watch_enabled: localState.watchEnabled,
|
|
482
|
-
max_files: 5000,
|
|
483
|
-
consent: {
|
|
484
|
-
ui: 'graph',
|
|
485
|
-
knowledge_source: true,
|
|
486
|
-
image_ocr: localState.includeOcr,
|
|
487
|
-
watch_enabled: localState.watchEnabled,
|
|
488
|
-
sensitive_files_default_excluded: true,
|
|
489
|
-
},
|
|
490
|
-
}, async data => {
|
|
491
|
-
localState.status = `${t('local_indexed')} · ${formatCount((data.counts || {}).indexed)} files`;
|
|
492
|
-
await Promise.all([loadGraph(), loadLocalSources()]);
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
window.selectLocalPath = selectLocalPath;
|
|
497
|
-
window.updateLocalPath = updateLocalPath;
|
|
498
|
-
window.setLocalOption = setLocalOption;
|
|
499
|
-
window.runLocalTree = runLocalTree;
|
|
500
|
-
window.runLocalAudit = runLocalAudit;
|
|
501
|
-
window.runLocalIndex = runLocalIndex;
|
|
502
|
-
window.approveLocalPermission = approveLocalPermission;
|
|
503
|
-
|
|
504
|
-
/* 테마 색상 — CSS 변수에서 캔버스 배경/텍스트를 읽어 다크모드 대응 */
|
|
505
|
-
let themeColors = { bg: '#ffffff', text: '#14162c', surface: '#ffffff' };
|
|
506
|
-
function refreshThemeColors() {
|
|
507
|
-
const cs = getComputedStyle(document.documentElement);
|
|
508
|
-
const read = (name, fallback) => {
|
|
509
|
-
const v = (cs.getPropertyValue(name) || '').trim();
|
|
510
|
-
return v || fallback;
|
|
511
|
-
};
|
|
512
|
-
themeColors = {
|
|
513
|
-
bg: read('--bg', '#ffffff'),
|
|
514
|
-
text: read('--text', '#14162c'),
|
|
515
|
-
surface: read('--surface', read('--surface-2', '#ffffff')),
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function nodeColor(type) {
|
|
520
|
-
return (TYPE_CONFIG[type] || {}).color || '#8fa8bb';
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
function edgeStyle(type) {
|
|
524
|
-
return EDGE_CONFIG[type] || { color: '#7f8f9d', label: type, width: 1.3 };
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function typeLabel(type) {
|
|
528
|
-
return (TYPE_CONFIG[type] || {}).label || type;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
function formatMetric(value, digits = 2) {
|
|
532
|
-
if (value === null || value === undefined || Number.isNaN(Number(value))) return '-';
|
|
533
|
-
const num = Number(value);
|
|
534
|
-
if (Math.abs(num) >= 1000) return num.toLocaleString();
|
|
535
|
-
return Number.isInteger(num) ? String(num) : num.toFixed(digits);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function formatUpdatedAt(updatedAt) {
|
|
539
|
-
if (!updatedAt) return '';
|
|
540
|
-
const stamp = new Date(updatedAt);
|
|
541
|
-
if (Number.isNaN(stamp.getTime())) return '';
|
|
542
|
-
const diffMs = Date.now() - stamp.getTime();
|
|
543
|
-
const diffDays = Math.floor(diffMs / 86400000);
|
|
544
|
-
if (diffDays <= 0) return t('today');
|
|
545
|
-
if (diffDays === 1) return t('day_ago');
|
|
546
|
-
if (diffDays < 30) return t('days_ago').replace('{n}', diffDays);
|
|
547
|
-
const diffMonths = Math.floor(diffDays / 30);
|
|
548
|
-
if (diffMonths < 12) return t('months_ago').replace('{n}', diffMonths);
|
|
549
|
-
const diffYears = Math.floor(diffMonths / 12);
|
|
550
|
-
return t('years_ago').replace('{n}', diffYears);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function updateStats() {
|
|
554
|
-
document.getElementById('node-count').textContent = rawGraph.nodes.length.toLocaleString();
|
|
555
|
-
document.getElementById('edge-count').textContent = rawGraph.edges.length.toLocaleString();
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
function computeVisuals() {
|
|
559
|
-
const degreeMap = {};
|
|
560
|
-
rawGraph.edges.forEach(edge => {
|
|
561
|
-
degreeMap[edge.from] = (degreeMap[edge.from] || 0) + 1;
|
|
562
|
-
degreeMap[edge.to] = (degreeMap[edge.to] || 0) + 1;
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
rawGraph.nodes.forEach(node => {
|
|
566
|
-
const metrics = ((node.metadata || {}).graph_metrics) || {};
|
|
567
|
-
const importanceNorm = clamp(
|
|
568
|
-
Number.isFinite(Number(node.importance_norm))
|
|
569
|
-
? Number(node.importance_norm)
|
|
570
|
-
: Number(metrics.importance_norm || 0),
|
|
571
|
-
0,
|
|
572
|
-
1
|
|
573
|
-
);
|
|
574
|
-
node.degree = degreeMap[node.id] || Number(metrics.degree || 0) || 0;
|
|
575
|
-
node.importance_norm = importanceNorm;
|
|
576
|
-
node.importance = Number.isFinite(Number(node.importance))
|
|
577
|
-
? Number(node.importance)
|
|
578
|
-
: Number(metrics.importance_raw || 0);
|
|
579
|
-
|
|
580
|
-
let radius = 10;
|
|
581
|
-
if (node.type === 'Topic') {
|
|
582
|
-
radius = 20 + importanceNorm * 24 + Math.sqrt(node.degree) * 1.2;
|
|
583
|
-
} else if (node.type === 'Conversation') {
|
|
584
|
-
radius = 16 + importanceNorm * 14 + Math.sqrt(node.degree) * 0.8;
|
|
585
|
-
} else if (node.type === 'File') {
|
|
586
|
-
radius = 15 + importanceNorm * 12 + Math.sqrt(node.degree) * 0.7;
|
|
587
|
-
} else if (node.type === 'Decision' || node.type === 'Task') {
|
|
588
|
-
radius = 14 + importanceNorm * 11 + Math.sqrt(node.degree) * 0.65;
|
|
589
|
-
} else {
|
|
590
|
-
radius = 13 + importanceNorm * 9 + Math.sqrt(node.degree) * 0.5;
|
|
591
|
-
}
|
|
592
|
-
const maxRadius = node.type === 'Topic' ? 52 : 38;
|
|
593
|
-
node.r = clamp(radius, node.type === 'Topic' ? 18 : 12, maxRadius);
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
function buildTypeCounts() {
|
|
598
|
-
const counts = {};
|
|
599
|
-
rawGraph.nodes.forEach(node => {
|
|
600
|
-
counts[node.type] = (counts[node.type] || 0) + 1;
|
|
601
|
-
});
|
|
602
|
-
return counts;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function buildEdgeCounts() {
|
|
606
|
-
const counts = {};
|
|
607
|
-
rawGraph.edges.forEach(edge => {
|
|
608
|
-
counts[edge.type] = (counts[edge.type] || 0) + 1;
|
|
609
|
-
});
|
|
610
|
-
return counts;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function edgeKey(edgeOrFrom, to) {
|
|
614
|
-
if (typeof edgeOrFrom === 'object') {
|
|
615
|
-
return `${edgeOrFrom.from}|${edgeOrFrom.to}`;
|
|
616
|
-
}
|
|
617
|
-
return `${edgeOrFrom}|${to}`;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
function computeSubgraphIds(rootId, depth = 2) {
|
|
621
|
-
if (!rootId) return null;
|
|
622
|
-
const adjacency = new Map();
|
|
623
|
-
rawGraph.edges.forEach(edge => {
|
|
624
|
-
if (!edge.from || !edge.to) return;
|
|
625
|
-
if (!adjacency.has(edge.from)) adjacency.set(edge.from, new Set());
|
|
626
|
-
if (!adjacency.has(edge.to)) adjacency.set(edge.to, new Set());
|
|
627
|
-
adjacency.get(edge.from).add(edge.to);
|
|
628
|
-
adjacency.get(edge.to).add(edge.from);
|
|
629
|
-
});
|
|
630
|
-
const visible = new Set([rootId]);
|
|
631
|
-
let frontier = new Set([rootId]);
|
|
632
|
-
for (let i = 0; i < depth; i++) {
|
|
633
|
-
const next = new Set();
|
|
634
|
-
frontier.forEach(id => {
|
|
635
|
-
(adjacency.get(id) || []).forEach(neighbor => {
|
|
636
|
-
if (!visible.has(neighbor)) {
|
|
637
|
-
visible.add(neighbor);
|
|
638
|
-
next.add(neighbor);
|
|
639
|
-
}
|
|
640
|
-
});
|
|
641
|
-
});
|
|
642
|
-
frontier = next;
|
|
643
|
-
}
|
|
644
|
-
pathNodeIds.forEach(id => visible.add(id));
|
|
645
|
-
return visible;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
function applyFilter() {
|
|
649
|
-
const focusIds = computeSubgraphIds(focusNodeId, focusDepth);
|
|
650
|
-
graph.nodes = rawGraph.nodes.filter(node => {
|
|
651
|
-
if (hiddenTypes.has(node.type)) return false;
|
|
652
|
-
if (focusIds && !focusIds.has(node.id)) return false;
|
|
653
|
-
if (hiddenNodeIds.has(node.id) && node.id !== selected?.id && node.id !== focusNodeId && !pathNodeIds.has(node.id)) return false;
|
|
654
|
-
return true;
|
|
655
|
-
});
|
|
656
|
-
const nodeSet = new Set(graph.nodes.map(node => node.id));
|
|
657
|
-
const byId = Object.fromEntries(rawGraph.nodes.map(node => [node.id, node]));
|
|
658
|
-
graph.edges = rawGraph.edges
|
|
659
|
-
.filter(edge => nodeSet.has(edge.from) && nodeSet.has(edge.to))
|
|
660
|
-
.filter(edge => !hiddenEdgeTypes.has(edge.type))
|
|
661
|
-
.map(edge => ({ ...edge, source: byId[edge.from], target: byId[edge.to] }));
|
|
662
|
-
renderFocusChip();
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
function seedLayout() {
|
|
666
|
-
rawGraph.nodes.forEach((node, index) => {
|
|
667
|
-
if (node.x === undefined || node.y === undefined) {
|
|
668
|
-
const angle = (index / Math.max(1, rawGraph.nodes.length)) * Math.PI * 2;
|
|
669
|
-
const ring = Math.min(width, height) * (node.type === 'Topic' ? 0.22 : 0.32);
|
|
670
|
-
node.x = width / 2 + Math.cos(angle) * ring;
|
|
671
|
-
node.y = height / 2 + Math.sin(angle) * ring;
|
|
672
|
-
}
|
|
673
|
-
node.vx = node.vx || 0;
|
|
674
|
-
node.vy = node.vy || 0;
|
|
675
|
-
node._pinned = false;
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/* 방사형(허브-스포크) 레이아웃 — 최고 연결 노드를 중심에 고정 */
|
|
680
|
-
function radialLayout() {
|
|
681
|
-
const nodes = rawGraph.nodes;
|
|
682
|
-
if (!nodes.length) return;
|
|
683
|
-
|
|
684
|
-
nodes.forEach(n => { n._pinned = false; });
|
|
685
|
-
|
|
686
|
-
// 가장 연결이 많은 노드 찾기
|
|
687
|
-
const deg = {};
|
|
688
|
-
rawGraph.edges.forEach(e => {
|
|
689
|
-
deg[e.from] = (deg[e.from] || 0) + 1;
|
|
690
|
-
deg[e.to] = (deg[e.to] || 0) + 1;
|
|
691
|
-
});
|
|
692
|
-
const sorted = [...nodes].sort((a, b) =>
|
|
693
|
-
((deg[b.id] || 0) + (b.importance_norm || 0) * 5) -
|
|
694
|
-
((deg[a.id] || 0) + (a.importance_norm || 0) * 5)
|
|
695
|
-
);
|
|
696
|
-
|
|
697
|
-
const hub = sorted[0];
|
|
698
|
-
const others = sorted.slice(1);
|
|
699
|
-
|
|
700
|
-
const cx = width / 2;
|
|
701
|
-
const cy = height / 2;
|
|
702
|
-
|
|
703
|
-
// 허브 노드 중앙 고정
|
|
704
|
-
hub.x = cx; hub.y = cy;
|
|
705
|
-
hub.vx = 0; hub.vy = 0;
|
|
706
|
-
hub._pinned = true;
|
|
707
|
-
|
|
708
|
-
// 나머지를 1~2개 링에 배치
|
|
709
|
-
const INNER_MAX = Math.min(others.length, 10);
|
|
710
|
-
const innerNodes = others.slice(0, INNER_MAX);
|
|
711
|
-
const outerNodes = others.slice(INNER_MAX);
|
|
712
|
-
|
|
713
|
-
const shortSide = Math.min(width, height);
|
|
714
|
-
const innerR = shortSide * 0.27;
|
|
715
|
-
const outerR = shortSide * 0.46;
|
|
716
|
-
|
|
717
|
-
innerNodes.forEach((node, i) => {
|
|
718
|
-
const angle = (i / innerNodes.length) * Math.PI * 2 - Math.PI / 2;
|
|
719
|
-
node.x = cx + Math.cos(angle) * innerR;
|
|
720
|
-
node.y = cy + Math.sin(angle) * innerR;
|
|
721
|
-
node.vx = 0; node.vy = 0;
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
outerNodes.forEach((node, i) => {
|
|
725
|
-
const angle = (i / outerNodes.length) * Math.PI * 2 - Math.PI / 2;
|
|
726
|
-
node.x = cx + Math.cos(angle) * outerR;
|
|
727
|
-
node.y = cy + Math.sin(angle) * outerR;
|
|
728
|
-
node.vx = 0; node.vy = 0;
|
|
729
|
-
});
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function mergeGraphData(extraNodes, extraEdges) {
|
|
733
|
-
const nodeMap = new Map(rawGraph.nodes.map(node => [node.id, node]));
|
|
734
|
-
extraNodes.forEach(node => {
|
|
735
|
-
const prev = nodeMap.get(node.id) || {};
|
|
736
|
-
nodeMap.set(node.id, {
|
|
737
|
-
...prev,
|
|
738
|
-
...node,
|
|
739
|
-
metadata: { ...(prev.metadata || {}), ...(node.metadata || {}) },
|
|
740
|
-
});
|
|
741
|
-
});
|
|
742
|
-
rawGraph.nodes = [...nodeMap.values()];
|
|
743
|
-
|
|
744
|
-
const edgeMap = new Map(rawGraph.edges.map(edge => [edge.id || `${edge.from}|${edge.type}|${edge.to}`, edge]));
|
|
745
|
-
extraEdges.forEach(edge => {
|
|
746
|
-
const key = edge.id || `${edge.from}|${edge.type}|${edge.to}`;
|
|
747
|
-
edgeMap.set(key, edge);
|
|
748
|
-
});
|
|
749
|
-
rawGraph.edges = [...edgeMap.values()];
|
|
750
|
-
|
|
751
|
-
computeVisuals();
|
|
752
|
-
seedLayout();
|
|
753
|
-
applyFilter();
|
|
754
|
-
updateStats();
|
|
755
|
-
renderTypeFilters(buildTypeCounts());
|
|
756
|
-
renderEdgeLegend(buildEdgeCounts());
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
async function loadGraph() {
|
|
760
|
-
updateStats();
|
|
761
|
-
const [graphRes, statsRes] = await Promise.all([
|
|
762
|
-
apiFetch('/knowledge-graph/graph?limit=600'),
|
|
763
|
-
apiFetch('/knowledge-graph/stats'),
|
|
764
|
-
]);
|
|
765
|
-
if (graphRes.status === 401) {
|
|
766
|
-
window.location.href = '/account';
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
if (!graphRes.ok) throw new Error(`Graph API failed (${graphRes.status})`);
|
|
770
|
-
|
|
771
|
-
const graphData = await graphRes.json();
|
|
772
|
-
const stats = statsRes.ok ? await statsRes.json() : {};
|
|
773
|
-
rawGraph = {
|
|
774
|
-
nodes: Array.isArray(graphData.nodes) ? graphData.nodes : [],
|
|
775
|
-
edges: Array.isArray(graphData.edges) ? graphData.edges : [],
|
|
776
|
-
};
|
|
777
|
-
computeVisuals();
|
|
778
|
-
seedLayout();
|
|
779
|
-
radialLayout();
|
|
780
|
-
applyFilter();
|
|
781
|
-
updateStats();
|
|
782
|
-
renderTypeFilters(stats.nodes || buildTypeCounts());
|
|
783
|
-
renderEdgeLegend(stats.edges || {});
|
|
784
|
-
const urlNode = new URLSearchParams(window.location.search).get('node');
|
|
785
|
-
const initialNode = (urlNode && rawGraph.nodes.find(node => node.id === urlNode))
|
|
786
|
-
|| (selected && rawGraph.nodes.find(node => node.id === selected.id))
|
|
787
|
-
|| graph.nodes[0]
|
|
788
|
-
|| null;
|
|
789
|
-
cam = { scale: 1, tx: 0, ty: 0 };
|
|
790
|
-
showDetail(initialNode);
|
|
791
|
-
if (initialNode && urlNode) centerOnNode(initialNode, Math.max(cam.scale, 1));
|
|
792
|
-
wakeUp();
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
function renderTypeFilters(typeCounts) {
|
|
796
|
-
const presentTypes = [...new Set(rawGraph.nodes.map(node => node.type))];
|
|
797
|
-
const ordered = [...Object.keys(TYPE_CONFIG), ...presentTypes.filter(type => !TYPE_CONFIG[type])]
|
|
798
|
-
.filter(type => presentTypes.includes(type));
|
|
799
|
-
const container = document.getElementById('type-filters');
|
|
800
|
-
if (!ordered.length) {
|
|
801
|
-
container.innerHTML = `<div class="empty-hint">${t('no_node_types')}</div>`;
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
|
-
container.innerHTML = ordered.map(type => {
|
|
805
|
-
const checked = hiddenTypes.has(type) ? '' : 'checked';
|
|
806
|
-
return `
|
|
807
|
-
<label class="filter-item">
|
|
808
|
-
<input type="checkbox" ${checked} onchange="toggleType('${type}', this.checked)">
|
|
809
|
-
<span class="dot" style="background:${nodeColor(type)}"></span>
|
|
810
|
-
<span class="filter-name">${escapeHtml(typeLabel(type))}</span>
|
|
811
|
-
<span class="filter-count">${typeCounts[type] || 0}</span>
|
|
812
|
-
</label>
|
|
813
|
-
`;
|
|
814
|
-
}).join('');
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
function renderEdgeLegend(edgeCounts) {
|
|
818
|
-
const presentEdgeTypes = [...new Set(rawGraph.edges.map(edge => edge.type))];
|
|
819
|
-
const ordered = [...Object.keys(EDGE_CONFIG), ...presentEdgeTypes.filter(type => !EDGE_CONFIG[type])]
|
|
820
|
-
.filter(type => presentEdgeTypes.includes(type));
|
|
821
|
-
const container = document.getElementById('edge-legend');
|
|
822
|
-
if (!ordered.length) {
|
|
823
|
-
container.innerHTML = `<div class="empty-hint">${t('no_relationships')}</div>`;
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
container.innerHTML = ordered.map(type => {
|
|
827
|
-
const style = edgeStyle(type);
|
|
828
|
-
const checked = hiddenEdgeTypes.has(type) ? '' : 'checked';
|
|
829
|
-
return `
|
|
830
|
-
<label class="filter-item">
|
|
831
|
-
<input type="checkbox" ${checked} onchange="toggleEdgeType(decodeURIComponent('${encodeURIComponent(type)}'), this.checked)">
|
|
832
|
-
<span class="legend-line" style="border-top-color:${style.color}; border-top-width:${Math.max(2, style.width)}px;"></span>
|
|
833
|
-
<span class="filter-name">${escapeHtml(style.label || type)}</span>
|
|
834
|
-
<span class="filter-count">${edgeCounts[type] || 0}</span>
|
|
835
|
-
</label>
|
|
836
|
-
`;
|
|
837
|
-
}).join('');
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
function toggleEdgeType(type, visible) {
|
|
841
|
-
if (visible) hiddenEdgeTypes.delete(type);
|
|
842
|
-
else hiddenEdgeTypes.add(type);
|
|
843
|
-
applyFilter();
|
|
844
|
-
wakeUp();
|
|
845
|
-
}
|
|
846
|
-
window.toggleEdgeType = toggleEdgeType;
|
|
847
|
-
|
|
848
|
-
function toggleType(type, visible) {
|
|
849
|
-
if (visible) hiddenTypes.delete(type);
|
|
850
|
-
else hiddenTypes.add(type);
|
|
851
|
-
applyFilter();
|
|
852
|
-
if (selected && hiddenTypes.has(selected.type)) showDetail(null);
|
|
853
|
-
wakeUp();
|
|
854
|
-
}
|
|
855
|
-
window.toggleType = toggleType;
|
|
856
|
-
|
|
857
|
-
function renderFocusChip() {
|
|
858
|
-
const chip = document.getElementById('graph-focus-chip');
|
|
859
|
-
if (!chip) return;
|
|
860
|
-
const focusNode = rawGraph.nodes.find(node => node.id === focusNodeId);
|
|
861
|
-
const pathStart = rawGraph.nodes.find(node => node.id === pathStartId);
|
|
862
|
-
const parts = [];
|
|
863
|
-
if (focusNode) parts.push(`<span><i class="ti ti-focus-2"></i>${escapeHtml(focusNode.title || focusNode.id)}</span>`);
|
|
864
|
-
if (pathStart) parts.push(`<span><i class="ti ti-route"></i>${escapeHtml(t('path_ready').replace('{title}', pathStart.title || pathStart.id))}</span>`);
|
|
865
|
-
if (pathNodeIds.size) parts.push(`<span>${pathNodeIds.size} nodes</span>`);
|
|
866
|
-
chip.hidden = parts.length === 0;
|
|
867
|
-
chip.innerHTML = parts.join('');
|
|
868
|
-
applyI18n();
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
function relatedNodeIds(nodeId) {
|
|
872
|
-
const ids = new Set();
|
|
873
|
-
rawGraph.edges.forEach(edge => {
|
|
874
|
-
if (edge.from === nodeId) ids.add(edge.to);
|
|
875
|
-
if (edge.to === nodeId) ids.add(edge.from);
|
|
876
|
-
});
|
|
877
|
-
return ids;
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
function step() {
|
|
881
|
-
const nodes = graph.nodes;
|
|
882
|
-
const edges = graph.edges;
|
|
883
|
-
const centerPull = selected ? 0.00035 : 0.00055;
|
|
884
|
-
|
|
885
|
-
for (let i = 0; i < nodes.length; i++) {
|
|
886
|
-
for (let j = i + 1; j < nodes.length; j++) {
|
|
887
|
-
const a = nodes[i];
|
|
888
|
-
const b = nodes[j];
|
|
889
|
-
const dx = a.x - b.x;
|
|
890
|
-
const dy = a.y - b.y;
|
|
891
|
-
const d2 = Math.max(120, dx * dx + dy * dy);
|
|
892
|
-
const strength = (a.type === 'Topic' || b.type === 'Topic') ? 2900 : 2100;
|
|
893
|
-
const force = strength / d2;
|
|
894
|
-
a.vx += dx * force;
|
|
895
|
-
a.vy += dy * force;
|
|
896
|
-
b.vx -= dx * force;
|
|
897
|
-
b.vy -= dy * force;
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
edges.forEach(edge => {
|
|
902
|
-
if (!edge.source || !edge.target) return;
|
|
903
|
-
const dx = edge.target.x - edge.source.x;
|
|
904
|
-
const dy = edge.target.y - edge.source.y;
|
|
905
|
-
const dist = Math.max(1, Math.hypot(dx, dy));
|
|
906
|
-
const targetDistance = edge.type === 'mentions' || edge.type === 'discusses'
|
|
907
|
-
? 118
|
|
908
|
-
: edge.type === 'contains'
|
|
909
|
-
? 138
|
|
910
|
-
: 132;
|
|
911
|
-
const force = (dist - targetDistance) * (0.0038 + Math.min(0.003, (edge.weight || 1) * 0.0015));
|
|
912
|
-
edge.source.vx += (dx / dist) * force;
|
|
913
|
-
edge.source.vy += (dy / dist) * force;
|
|
914
|
-
edge.target.vx -= (dx / dist) * force;
|
|
915
|
-
edge.target.vy -= (dy / dist) * force;
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
let kineticEnergy = 0;
|
|
919
|
-
nodes.forEach(node => {
|
|
920
|
-
if (node === dragging) return;
|
|
921
|
-
const pull = node._pinned ? 0.55 : centerPull;
|
|
922
|
-
node.vx += (width / 2 - node.x) * pull;
|
|
923
|
-
node.vy += (height / 2 - node.y) * pull;
|
|
924
|
-
node.vx *= 0.84;
|
|
925
|
-
node.vy *= 0.84;
|
|
926
|
-
node.x += node.vx;
|
|
927
|
-
node.y += node.vy;
|
|
928
|
-
kineticEnergy += node.vx * node.vx + node.vy * node.vy;
|
|
929
|
-
});
|
|
930
|
-
return kineticEnergy;
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
function wakeUp() {
|
|
934
|
-
if (!animFrameId) animFrameId = requestAnimationFrame(draw);
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
const nbCache = new Map();
|
|
938
|
-
function neighborIds(node) {
|
|
939
|
-
if (nbCache.has(node.id)) return nbCache.get(node.id);
|
|
940
|
-
const ids = new Set([node.id]);
|
|
941
|
-
graph.edges.forEach(edge => {
|
|
942
|
-
if (edge.from === node.id) ids.add(edge.to);
|
|
943
|
-
if (edge.to === node.id) ids.add(edge.from);
|
|
944
|
-
});
|
|
945
|
-
nbCache.set(node.id, ids);
|
|
946
|
-
return ids;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
function draw() {
|
|
950
|
-
animFrameId = null;
|
|
951
|
-
const kineticEnergy = step();
|
|
952
|
-
nbCache.clear();
|
|
953
|
-
|
|
954
|
-
ctx.clearRect(0, 0, width, height);
|
|
955
|
-
ctx.save();
|
|
956
|
-
ctx.translate(cam.tx, cam.ty);
|
|
957
|
-
ctx.scale(cam.scale, cam.scale);
|
|
958
|
-
|
|
959
|
-
// LOD: 줌이 너무 작거나 노드가 많으면 레이블 생략 (모바일 성능)
|
|
960
|
-
const showLabels = cam.scale >= 0.5 && graph.nodes.length <= 220;
|
|
961
|
-
|
|
962
|
-
const active = hovered || selected;
|
|
963
|
-
const neighborSet = active ? neighborIds(active) : null;
|
|
964
|
-
|
|
965
|
-
graph.edges.forEach(edge => {
|
|
966
|
-
if (!edge.source || !edge.target) return;
|
|
967
|
-
const style = edgeStyle(edge.type);
|
|
968
|
-
const isNeighborEdge = neighborSet && neighborSet.has(edge.from) && neighborSet.has(edge.to);
|
|
969
|
-
const isPathEdge = pathEdgeKeys.has(edgeKey(edge)) || pathEdgeKeys.has(edgeKey(edge.to, edge.from));
|
|
970
|
-
const baseAlpha = isPathEdge ? 0.98 : (neighborSet ? (isNeighborEdge ? 0.88 : 0.07) : 0.34);
|
|
971
|
-
const widthBoost = isPathEdge ? 2.2 : (isNeighborEdge ? 0.5 : 0);
|
|
972
|
-
ctx.save();
|
|
973
|
-
ctx.globalAlpha = baseAlpha;
|
|
974
|
-
ctx.strokeStyle = isPathEdge ? '#f59e0b' : style.color;
|
|
975
|
-
ctx.lineWidth = (style.width + Math.min(3.4, (edge.weight || 1) * 1.1) + widthBoost) / cam.scale;
|
|
976
|
-
ctx.setLineDash(isPathEdge ? [] : (style.dash || []));
|
|
977
|
-
ctx.beginPath();
|
|
978
|
-
ctx.moveTo(edge.source.x, edge.source.y);
|
|
979
|
-
ctx.lineTo(edge.target.x, edge.target.y);
|
|
980
|
-
ctx.stroke();
|
|
981
|
-
ctx.restore();
|
|
982
|
-
});
|
|
983
|
-
|
|
984
|
-
graph.nodes.forEach(node => {
|
|
985
|
-
const isNeighbor = neighborSet ? neighborSet.has(node.id) : true;
|
|
986
|
-
const isSearchHit = searchResultIds.has(node.id);
|
|
987
|
-
const isPathNode = pathNodeIds.has(node.id);
|
|
988
|
-
const isSelected = node === selected;
|
|
989
|
-
const isHovered = node === hovered;
|
|
990
|
-
const alpha = neighborSet ? (isNeighbor ? 1 : 0.12) : 1;
|
|
991
|
-
const radius = node.r + (isSelected ? 4 : isHovered ? 2 : isPathNode ? 3.5 : isSearchHit ? 2.6 : 0);
|
|
992
|
-
|
|
993
|
-
ctx.globalAlpha = alpha;
|
|
994
|
-
|
|
995
|
-
if (node.type === 'Topic') {
|
|
996
|
-
const haloRadius = radius + 6 + node.importance_norm * 8;
|
|
997
|
-
const halo = ctx.createRadialGradient(node.x, node.y, radius * 0.4, node.x, node.y, haloRadius);
|
|
998
|
-
halo.addColorStop(0, `${nodeColor(node.type)}30`);
|
|
999
|
-
halo.addColorStop(1, `${nodeColor(node.type)}00`);
|
|
1000
|
-
ctx.fillStyle = halo;
|
|
1001
|
-
ctx.beginPath();
|
|
1002
|
-
ctx.arc(node.x, node.y, haloRadius, 0, Math.PI * 2);
|
|
1003
|
-
ctx.fill();
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// 노드 원 그리기 (흰 테두리 + 색상 채우기)
|
|
1007
|
-
ctx.fillStyle = nodeColor(node.type);
|
|
1008
|
-
ctx.beginPath();
|
|
1009
|
-
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
|
|
1010
|
-
ctx.fill();
|
|
1011
|
-
|
|
1012
|
-
// 흰 테두리
|
|
1013
|
-
ctx.strokeStyle = isSelected ? '#6f42e8' : 'rgba(255,255,255,0.85)';
|
|
1014
|
-
ctx.lineWidth = (isSelected ? 3.2 : isHovered ? 2.4 : 2.0) / cam.scale;
|
|
1015
|
-
ctx.beginPath();
|
|
1016
|
-
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
|
|
1017
|
-
ctx.stroke();
|
|
1018
|
-
|
|
1019
|
-
// 선택/호버 외곽 링
|
|
1020
|
-
if (isSelected || isHovered || isSearchHit || isPathNode) {
|
|
1021
|
-
ctx.strokeStyle = isPathNode ? '#f59e0b' : (isSelected ? '#6f42e8' : nodeColor(node.type));
|
|
1022
|
-
ctx.lineWidth = (isSelected || isPathNode ? 2.8 : 1.8) / cam.scale;
|
|
1023
|
-
ctx.globalAlpha = alpha * 0.55;
|
|
1024
|
-
ctx.beginPath();
|
|
1025
|
-
ctx.arc(node.x, node.y, radius + 5 / cam.scale, 0, Math.PI * 2);
|
|
1026
|
-
ctx.stroke();
|
|
1027
|
-
ctx.globalAlpha = alpha;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
// 레이블 표시 (LOD: 줌이 작거나 노드가 많으면 생략 — 모바일 성능)
|
|
1031
|
-
if (showLabels || isSelected || isHovered || isSearchHit) {
|
|
1032
|
-
const label = node.title.slice(0, 24);
|
|
1033
|
-
const fs = Math.max(9.5, 12 / cam.scale);
|
|
1034
|
-
ctx.font = `600 ${fs}px "SF Pro Display","Inter",system-ui`;
|
|
1035
|
-
const lw = ctx.measureText(label).width;
|
|
1036
|
-
const gap = (radius + 8) / cam.scale;
|
|
1037
|
-
const lx = node.x - lw / 2;
|
|
1038
|
-
const ly = node.y + gap + fs;
|
|
1039
|
-
const pad = 4 / cam.scale;
|
|
1040
|
-
const br = 5 / cam.scale;
|
|
1041
|
-
// 테마 대응 배경 pill (라이트=흰색, 다크=surface)
|
|
1042
|
-
ctx.globalAlpha = alpha > 0.5 ? alpha * 0.88 : alpha * 0.22;
|
|
1043
|
-
ctx.fillStyle = themeColors.surface;
|
|
1044
|
-
ctx.beginPath();
|
|
1045
|
-
if (ctx.roundRect) {
|
|
1046
|
-
ctx.roundRect(lx - pad, ly - fs, lw + pad * 2, fs + pad * 1.6, br);
|
|
1047
|
-
} else {
|
|
1048
|
-
ctx.rect(lx - pad, ly - fs, lw + pad * 2, fs + pad * 1.6);
|
|
1049
|
-
}
|
|
1050
|
-
ctx.fill();
|
|
1051
|
-
ctx.globalAlpha = alpha > 0.5 ? alpha : alpha * 0.3;
|
|
1052
|
-
ctx.fillStyle = themeColors.text;
|
|
1053
|
-
ctx.fillText(label, lx, ly);
|
|
1054
|
-
ctx.globalAlpha = alpha;
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
ctx.globalAlpha = 1;
|
|
1058
|
-
});
|
|
1059
|
-
|
|
1060
|
-
ctx.restore();
|
|
1061
|
-
drawMinimap();
|
|
1062
|
-
if (kineticEnergy > 0.04 || dragging) animFrameId = requestAnimationFrame(draw);
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
function toWorld(canvasX, canvasY) {
|
|
1066
|
-
return { x: (canvasX - cam.tx) / cam.scale, y: (canvasY - cam.ty) / cam.scale };
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
function nodeAt(canvasX, canvasY) {
|
|
1070
|
-
const { x, y } = toWorld(canvasX, canvasY);
|
|
1071
|
-
let best = null;
|
|
1072
|
-
let bestDistance = Infinity;
|
|
1073
|
-
graph.nodes.forEach(node => {
|
|
1074
|
-
const distance = Math.hypot(node.x - x, node.y - y);
|
|
1075
|
-
if (distance < (node.r + 10) / cam.scale && distance < bestDistance) {
|
|
1076
|
-
best = node;
|
|
1077
|
-
bestDistance = distance;
|
|
1078
|
-
}
|
|
1079
|
-
});
|
|
1080
|
-
return best;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
function fitToScreen() {
|
|
1084
|
-
if (!graph.nodes.length) return;
|
|
1085
|
-
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
1086
|
-
graph.nodes.forEach(node => {
|
|
1087
|
-
x0 = Math.min(x0, node.x - node.r);
|
|
1088
|
-
x1 = Math.max(x1, node.x + node.r);
|
|
1089
|
-
y0 = Math.min(y0, node.y - node.r);
|
|
1090
|
-
y1 = Math.max(y1, node.y + node.r);
|
|
1091
|
-
});
|
|
1092
|
-
const margin = 56;
|
|
1093
|
-
const scale = Math.min(
|
|
1094
|
-
2.8,
|
|
1095
|
-
Math.min(
|
|
1096
|
-
(width - margin * 2) / Math.max(1, x1 - x0),
|
|
1097
|
-
(height - margin * 2) / Math.max(1, y1 - y0)
|
|
1098
|
-
)
|
|
1099
|
-
);
|
|
1100
|
-
cam.scale = scale;
|
|
1101
|
-
cam.tx = (width - (x0 + x1) * scale) / 2;
|
|
1102
|
-
cam.ty = (height - (y0 + y1) * scale) / 2;
|
|
1103
|
-
wakeUp();
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
function centerOnNode(node, targetScale = cam.scale) {
|
|
1107
|
-
cam.scale = clamp(targetScale, 0.12, 4.5);
|
|
1108
|
-
cam.tx = width / 2 - node.x * cam.scale;
|
|
1109
|
-
cam.ty = height / 2 - node.y * cam.scale;
|
|
1110
|
-
wakeUp();
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
async function expandNode(node = selected) {
|
|
1114
|
-
if (!node) return;
|
|
1115
|
-
const res = await apiFetch(`/knowledge-graph/neighbors/${encodeURIComponent(node.id)}`);
|
|
1116
|
-
if (!res.ok) throw new Error(`Expand failed (${res.status})`);
|
|
1117
|
-
const payload = await res.json();
|
|
1118
|
-
const nodes = [
|
|
1119
|
-
payload.node || node,
|
|
1120
|
-
...((payload.neighbors || []).map(item => ({ ...item, updated_at: item.updated_at }))),
|
|
1121
|
-
];
|
|
1122
|
-
expandedNodeIds.add(node.id);
|
|
1123
|
-
nodes.forEach(item => hiddenNodeIds.delete(item.id));
|
|
1124
|
-
mergeGraphData(nodes, payload.edges || []);
|
|
1125
|
-
showDetail(rawGraph.nodes.find(item => item.id === node.id) || node);
|
|
1126
|
-
centerOnNode(node, Math.max(cam.scale, 1));
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
function collapseNode(node = selected) {
|
|
1130
|
-
if (!node) return;
|
|
1131
|
-
relatedNodeIds(node.id).forEach(id => {
|
|
1132
|
-
if (id !== node.id && id !== focusNodeId && !pathNodeIds.has(id)) hiddenNodeIds.add(id);
|
|
1133
|
-
});
|
|
1134
|
-
expandedNodeIds.delete(node.id);
|
|
1135
|
-
applyFilter();
|
|
1136
|
-
showDetail(node);
|
|
1137
|
-
wakeUp();
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
function toggleFocus(node = selected) {
|
|
1141
|
-
if (!node) return;
|
|
1142
|
-
if (focusNodeId === node.id) {
|
|
1143
|
-
focusNodeId = null;
|
|
1144
|
-
} else {
|
|
1145
|
-
focusNodeId = node.id;
|
|
1146
|
-
hiddenNodeIds.delete(node.id);
|
|
1147
|
-
}
|
|
1148
|
-
applyFilter();
|
|
1149
|
-
centerOnNode(node, Math.max(cam.scale, 0.9));
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
function localShortestPath(startId, targetId) {
|
|
1153
|
-
if (!startId || !targetId || startId === targetId) return startId ? [startId] : [];
|
|
1154
|
-
const adjacency = new Map();
|
|
1155
|
-
rawGraph.edges.forEach(edge => {
|
|
1156
|
-
if (!edge.from || !edge.to) return;
|
|
1157
|
-
if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
|
|
1158
|
-
if (!adjacency.has(edge.to)) adjacency.set(edge.to, []);
|
|
1159
|
-
adjacency.get(edge.from).push(edge.to);
|
|
1160
|
-
adjacency.get(edge.to).push(edge.from);
|
|
1161
|
-
});
|
|
1162
|
-
const queue = [[startId]];
|
|
1163
|
-
const seen = new Set([startId]);
|
|
1164
|
-
while (queue.length) {
|
|
1165
|
-
const path = queue.shift();
|
|
1166
|
-
const last = path[path.length - 1];
|
|
1167
|
-
if (last === targetId) return path;
|
|
1168
|
-
(adjacency.get(last) || []).forEach(next => {
|
|
1169
|
-
if (!seen.has(next)) {
|
|
1170
|
-
seen.add(next);
|
|
1171
|
-
queue.push([...path, next]);
|
|
1172
|
-
}
|
|
1173
|
-
});
|
|
1174
|
-
}
|
|
1175
|
-
return [];
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
async function showShortestPath(target = selected) {
|
|
1179
|
-
if (!target) return;
|
|
1180
|
-
if (!pathStartId) {
|
|
1181
|
-
pathStartId = target.id;
|
|
1182
|
-
renderFocusChip();
|
|
1183
|
-
showDetail(target);
|
|
1184
|
-
return;
|
|
1185
|
-
}
|
|
1186
|
-
let path = localShortestPath(pathStartId, target.id);
|
|
1187
|
-
if (!path.length || path[path.length - 1] !== target.id) {
|
|
1188
|
-
const res = await apiFetch(`/workspace/relationships/${encodeURIComponent(pathStartId)}?target_id=${encodeURIComponent(target.id)}`);
|
|
1189
|
-
if (res.ok) {
|
|
1190
|
-
const payload = await res.json();
|
|
1191
|
-
path = Array.isArray(payload.shortest_path) ? payload.shortest_path : path;
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
if (!path.length || path[path.length - 1] !== target.id) {
|
|
1195
|
-
searchCountEl.textContent = t('path_not_found');
|
|
1196
|
-
return;
|
|
1197
|
-
}
|
|
1198
|
-
pathNodeIds = new Set(path);
|
|
1199
|
-
pathEdgeKeys = new Set();
|
|
1200
|
-
for (let i = 0; i < path.length - 1; i++) {
|
|
1201
|
-
pathEdgeKeys.add(edgeKey(path[i], path[i + 1]));
|
|
1202
|
-
pathEdgeKeys.add(edgeKey(path[i + 1], path[i]));
|
|
1203
|
-
}
|
|
1204
|
-
path.forEach(id => hiddenNodeIds.delete(id));
|
|
1205
|
-
applyFilter();
|
|
1206
|
-
const first = rawGraph.nodes.find(node => node.id === path[0]);
|
|
1207
|
-
const last = rawGraph.nodes.find(node => node.id === path[path.length - 1]);
|
|
1208
|
-
if (first && last) {
|
|
1209
|
-
const x0 = Math.min(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.x ?? first.x));
|
|
1210
|
-
const x1 = Math.max(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.x ?? first.x));
|
|
1211
|
-
const y0 = Math.min(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.y ?? first.y));
|
|
1212
|
-
const y1 = Math.max(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.y ?? first.y));
|
|
1213
|
-
cam.scale = clamp(Math.min(width / Math.max(260, x1 - x0 + 180), height / Math.max(220, y1 - y0 + 160)), 0.4, 2.4);
|
|
1214
|
-
cam.tx = width / 2 - ((x0 + x1) / 2) * cam.scale;
|
|
1215
|
-
cam.ty = height / 2 - ((y0 + y1) / 2) * cam.scale;
|
|
1216
|
-
}
|
|
1217
|
-
showDetail(target);
|
|
1218
|
-
wakeUp();
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
function clearPath() {
|
|
1222
|
-
pathStartId = selected?.id || null;
|
|
1223
|
-
pathNodeIds = new Set();
|
|
1224
|
-
pathEdgeKeys = new Set();
|
|
1225
|
-
renderFocusChip();
|
|
1226
|
-
applyFilter();
|
|
1227
|
-
wakeUp();
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
function metricCards(node) {
|
|
1231
|
-
const metrics = ((node.metadata || {}).graph_metrics) || {};
|
|
1232
|
-
const cards = [
|
|
1233
|
-
{ value: formatMetric(metrics.importance_norm ? metrics.importance_norm * 100 : 0, 0), label: 'Importance %' },
|
|
1234
|
-
{ value: formatMetric(metrics.degree || node.degree || 0, 0), label: 'Connections' },
|
|
1235
|
-
];
|
|
1236
|
-
if (node.type === 'Topic') {
|
|
1237
|
-
cards.push({ value: formatMetric(metrics.mention_count || 0, 0), label: 'Mentions' });
|
|
1238
|
-
cards.push({ value: formatMetric(metrics.conversation_count || 0, 0), label: 'Conversations' });
|
|
1239
|
-
} else {
|
|
1240
|
-
cards.push({ value: formatMetric(metrics.recency_score || 0), label: 'Recency' });
|
|
1241
|
-
cards.push({ value: formatMetric(node.importance || metrics.importance_raw || 0), label: 'Raw score' });
|
|
1242
|
-
}
|
|
1243
|
-
return `<div class="metric-grid">${cards.map(card => `
|
|
1244
|
-
<div class="metric-card">
|
|
1245
|
-
<strong>${escapeHtml(card.value)}</strong>
|
|
1246
|
-
<span>${escapeHtml(card.label)}</span>
|
|
1247
|
-
</div>
|
|
1248
|
-
`).join('')}</div>`;
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
function showDetail(node) {
|
|
1252
|
-
if (!node) {
|
|
1253
|
-
selected = null;
|
|
1254
|
-
detail.innerHTML = `<p class="empty-hint">${t('detail_empty_short')}</p>`;
|
|
1255
|
-
wakeUp();
|
|
1256
|
-
return;
|
|
1257
|
-
}
|
|
1258
|
-
selected = node;
|
|
1259
|
-
const meta = node.metadata || {};
|
|
1260
|
-
const convId = meta.conversation_id;
|
|
1261
|
-
const sourcePath = meta.path || meta.absolute_path || meta.root_path || meta.source_path || '';
|
|
1262
|
-
const jumpHtml = convId
|
|
1263
|
-
? `<a class="jump-btn" href="${API_BASE}/chat?open_conversation=${encodeURIComponent(convId)}">${t('open_in_chat')}</a>`
|
|
1264
|
-
: '';
|
|
1265
|
-
const sourceHtml = sourcePath
|
|
1266
|
-
? `<a class="jump-btn secondary" href="${API_BASE}/local/serve?path=${encodeURIComponent(sourcePath)}">${t('source_open')}</a>`
|
|
1267
|
-
: '';
|
|
1268
|
-
const metrics = metricCards(node);
|
|
1269
|
-
const updatedAt = formatUpdatedAt(node.updated_at);
|
|
1270
|
-
const source = meta.relative_path || meta.filename || meta.conversation_id || meta.source || '';
|
|
1271
|
-
const metadataStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
|
|
1272
|
-
const relatedRows = rawGraph.edges
|
|
1273
|
-
.filter(edge => edge.from === node.id || edge.to === node.id)
|
|
1274
|
-
.slice(0, 10)
|
|
1275
|
-
.map(edge => {
|
|
1276
|
-
const otherId = edge.from === node.id ? edge.to : edge.from;
|
|
1277
|
-
const other = rawGraph.nodes.find(item => item.id === otherId) || { id: otherId, title: otherId, type: 'Event' };
|
|
1278
|
-
const direction = edge.from === node.id ? '→' : '←';
|
|
1279
|
-
return `
|
|
1280
|
-
<button class="related-node-btn" data-detail-node="${escapeHtml(otherId)}">
|
|
1281
|
-
<span>${direction}</span>
|
|
1282
|
-
<strong>${escapeHtml(other.title || other.id)}</strong>
|
|
1283
|
-
<em>${escapeHtml(edge.type || 'related')}</em>
|
|
1284
|
-
</button>
|
|
1285
|
-
`;
|
|
1286
|
-
}).join('');
|
|
1287
|
-
detail.innerHTML = `
|
|
1288
|
-
<div class="type-badge" style="background:${nodeColor(node.type)}">${escapeHtml(typeLabel(node.type))}</div>
|
|
1289
|
-
<div class="detail-title">${escapeHtml(node.title || node.id)}</div>
|
|
1290
|
-
${node.summary ? `<div class="detail-summary">${escapeHtml(node.summary)}</div>` : ''}
|
|
1291
|
-
<div class="detail-actions">
|
|
1292
|
-
${jumpHtml}
|
|
1293
|
-
${sourceHtml}
|
|
1294
|
-
<button class="jump-btn secondary" data-graph-action="expand">${t('expand')}</button>
|
|
1295
|
-
<button class="jump-btn secondary" data-graph-action="focus">${focusNodeId === node.id ? t('clear_focus') : t('focus')}</button>
|
|
1296
|
-
<button class="jump-btn secondary" data-graph-action="path-start">${t('path_start')}</button>
|
|
1297
|
-
</div>
|
|
1298
|
-
${metrics}
|
|
1299
|
-
<div class="detail-summary">
|
|
1300
|
-
${source ? `<strong>source:</strong> ${escapeHtml(source)}<br>` : ''}
|
|
1301
|
-
${updatedAt ? `<strong>updated:</strong> ${escapeHtml(updatedAt)}` : ''}
|
|
1302
|
-
</div>
|
|
1303
|
-
${relatedRows ? `<div class="related-node-list">${relatedRows}</div>` : ''}
|
|
1304
|
-
${metadataStr ? `<div class="meta-block">${escapeHtml(metadataStr)}</div>` : ''}
|
|
1305
|
-
`;
|
|
1306
|
-
detail.querySelectorAll('[data-graph-action]').forEach(btn => {
|
|
1307
|
-
btn.addEventListener('click', () => {
|
|
1308
|
-
const action = btn.dataset.graphAction;
|
|
1309
|
-
if (action === 'expand') expandNode(node).catch(error => { searchCountEl.textContent = error.message; });
|
|
1310
|
-
if (action === 'focus') toggleFocus(node);
|
|
1311
|
-
if (action === 'path-start') {
|
|
1312
|
-
pathStartId = node.id;
|
|
1313
|
-
pathNodeIds = new Set();
|
|
1314
|
-
pathEdgeKeys = new Set();
|
|
1315
|
-
renderFocusChip();
|
|
1316
|
-
wakeUp();
|
|
1317
|
-
}
|
|
1318
|
-
});
|
|
1319
|
-
});
|
|
1320
|
-
detail.querySelectorAll('[data-detail-node]').forEach(btn => {
|
|
1321
|
-
btn.addEventListener('click', () => {
|
|
1322
|
-
const next = rawGraph.nodes.find(item => item.id === btn.dataset.detailNode);
|
|
1323
|
-
if (next) {
|
|
1324
|
-
showDetail(next);
|
|
1325
|
-
centerOnNode(next, Math.max(cam.scale, 0.95));
|
|
1326
|
-
}
|
|
1327
|
-
});
|
|
1328
|
-
});
|
|
1329
|
-
wakeUp();
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
function resize() {
|
|
1333
|
-
const rect = canvas.getBoundingClientRect();
|
|
1334
|
-
width = rect.width;
|
|
1335
|
-
height = rect.height;
|
|
1336
|
-
const dpr = window.devicePixelRatio || 1;
|
|
1337
|
-
canvas.width = Math.floor(width * dpr);
|
|
1338
|
-
canvas.height = Math.floor(height * dpr);
|
|
1339
|
-
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
function setSearchIdleState(message = t('ready')) {
|
|
1343
|
-
searchCountEl.textContent = message;
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
function renderSearchResults() {
|
|
1347
|
-
document.querySelector('.search-shell')?.classList.toggle('search-open', Boolean(searchInput.value.trim()));
|
|
1348
|
-
if (!searchInput.value.trim()) {
|
|
1349
|
-
searchResultsEl.innerHTML = `<p class="search-empty">${t('search_empty')}</p>`;
|
|
1350
|
-
return;
|
|
1351
|
-
}
|
|
1352
|
-
if (!searchResults.length) {
|
|
1353
|
-
searchResultsEl.innerHTML = `<p class="search-empty">${t('search_no_results')}</p>`;
|
|
1354
|
-
return;
|
|
1355
|
-
}
|
|
1356
|
-
searchResultsEl.innerHTML = `
|
|
1357
|
-
<div class="search-list">
|
|
1358
|
-
${searchResults.map(match => {
|
|
1359
|
-
const active = selected && selected.id === match.id ? 'active' : '';
|
|
1360
|
-
const source = (match.metadata || {}).relative_path || (match.metadata || {}).filename || (match.metadata || {}).conversation_id || '';
|
|
1361
|
-
return `
|
|
1362
|
-
<button class="search-item ${active}" data-node-id="${escapeHtml(match.id)}">
|
|
1363
|
-
<div class="search-item-top">
|
|
1364
|
-
<span class="search-type" style="background:${nodeColor(match.type)}">${escapeHtml(typeLabel(match.type))}</span>
|
|
1365
|
-
<span class="search-item-title">${escapeHtml(match.title || match.id)}</span>
|
|
1366
|
-
</div>
|
|
1367
|
-
${match.summary ? `<p class="search-item-summary">${escapeHtml(match.summary)}</p>` : ''}
|
|
1368
|
-
<div class="search-item-meta">
|
|
1369
|
-
${source ? `<span>${escapeHtml(source)}</span>` : ''}
|
|
1370
|
-
${match.updated_at ? `<span>${escapeHtml(formatUpdatedAt(match.updated_at))}</span>` : ''}
|
|
1371
|
-
</div>
|
|
1372
|
-
</button>
|
|
1373
|
-
`;
|
|
1374
|
-
}).join('')}
|
|
1375
|
-
</div>
|
|
1376
|
-
`;
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
async function runSearch(query) {
|
|
1380
|
-
const trimmed = String(query || '').trim();
|
|
1381
|
-
if (!trimmed) {
|
|
1382
|
-
searchResults = [];
|
|
1383
|
-
searchResultIds = new Set();
|
|
1384
|
-
setSearchIdleState(t('ready'));
|
|
1385
|
-
renderSearchResults();
|
|
1386
|
-
wakeUp();
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
if (searchAbortController) searchAbortController.abort();
|
|
1391
|
-
searchAbortController = new AbortController();
|
|
1392
|
-
searchCountEl.textContent = t('searching');
|
|
1393
|
-
searchResultsEl.innerHTML = `<p class="search-loading">${t('search_loading')}</p>`;
|
|
1394
|
-
|
|
1395
|
-
try {
|
|
1396
|
-
const res = await apiFetch(`/knowledge-graph/search?q=${encodeURIComponent(trimmed)}&limit=12`, {
|
|
1397
|
-
signal: searchAbortController.signal,
|
|
1398
|
-
});
|
|
1399
|
-
if (!res.ok) throw new Error(`Search failed (${res.status})`);
|
|
1400
|
-
const data = await res.json();
|
|
1401
|
-
searchResults = Array.isArray(data.matches) ? data.matches : [];
|
|
1402
|
-
searchResultIds = new Set(searchResults.map(match => match.id));
|
|
1403
|
-
searchCountEl.textContent = t('search_results').replace('{n}', searchResults.length);
|
|
1404
|
-
renderSearchResults();
|
|
1405
|
-
wakeUp();
|
|
1406
|
-
} catch (error) {
|
|
1407
|
-
if (error.name === 'AbortError') return;
|
|
1408
|
-
searchResults = [];
|
|
1409
|
-
searchResultIds = new Set();
|
|
1410
|
-
searchCountEl.textContent = t('error');
|
|
1411
|
-
searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
|
|
1412
|
-
wakeUp();
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
function scheduleSearch() {
|
|
1417
|
-
clearTimeout(searchDebounceId);
|
|
1418
|
-
searchDebounceId = setTimeout(() => runSearch(searchInput.value), 160);
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
function clearSearch() {
|
|
1422
|
-
searchInput.value = '';
|
|
1423
|
-
searchResults = [];
|
|
1424
|
-
searchResultIds = new Set();
|
|
1425
|
-
setSearchIdleState(t('ready'));
|
|
1426
|
-
renderSearchResults();
|
|
1427
|
-
wakeUp();
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
async function focusSearchResult(match) {
|
|
1431
|
-
let node = rawGraph.nodes.find(item => item.id === match.id);
|
|
1432
|
-
if (!node) {
|
|
1433
|
-
const res = await apiFetch(`/knowledge-graph/neighbors/${encodeURIComponent(match.id)}`);
|
|
1434
|
-
if (res.ok) {
|
|
1435
|
-
const payload = await res.json();
|
|
1436
|
-
mergeGraphData([
|
|
1437
|
-
{
|
|
1438
|
-
id: match.id,
|
|
1439
|
-
type: match.type,
|
|
1440
|
-
title: match.title,
|
|
1441
|
-
summary: match.summary,
|
|
1442
|
-
metadata: match.metadata,
|
|
1443
|
-
updated_at: match.updated_at,
|
|
1444
|
-
},
|
|
1445
|
-
...((payload.neighbors || []).map(nodeItem => ({
|
|
1446
|
-
...nodeItem,
|
|
1447
|
-
updated_at: nodeItem.updated_at,
|
|
1448
|
-
}))),
|
|
1449
|
-
], payload.edges || []);
|
|
1450
|
-
node = rawGraph.nodes.find(item => item.id === match.id);
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
if (!node) return;
|
|
1454
|
-
showDetail(node);
|
|
1455
|
-
centerOnNode(node, Math.max(cam.scale, node.type === 'Topic' ? 1.15 : 0.95));
|
|
1456
|
-
renderSearchResults();
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
canvas.addEventListener('mousedown', event => {
|
|
1460
|
-
const rect = canvas.getBoundingClientRect();
|
|
1461
|
-
const canvasX = event.clientX - rect.left;
|
|
1462
|
-
const canvasY = event.clientY - rect.top;
|
|
1463
|
-
const node = nodeAt(canvasX, canvasY);
|
|
1464
|
-
if (node) {
|
|
1465
|
-
dragging = node;
|
|
1466
|
-
showDetail(node);
|
|
1467
|
-
} else {
|
|
1468
|
-
panning = { sx: event.clientX, sy: event.clientY, tx0: cam.tx, ty0: cam.ty };
|
|
1469
|
-
canvas.classList.add('panning');
|
|
1470
|
-
}
|
|
1471
|
-
wakeUp();
|
|
1472
|
-
});
|
|
1473
|
-
|
|
1474
|
-
canvas.addEventListener('mousemove', event => {
|
|
1475
|
-
const rect = canvas.getBoundingClientRect();
|
|
1476
|
-
const node = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
|
|
1477
|
-
if (node !== hovered) {
|
|
1478
|
-
hovered = node;
|
|
1479
|
-
wakeUp();
|
|
1480
|
-
}
|
|
1481
|
-
canvas.style.cursor = panning ? 'grabbing' : (node ? 'pointer' : 'grab');
|
|
1482
|
-
if (node) {
|
|
1483
|
-
const metrics = ((node.metadata || {}).graph_metrics) || {};
|
|
1484
|
-
tooltip.style.display = 'block';
|
|
1485
|
-
tooltip.style.left = `${event.clientX + 14}px`;
|
|
1486
|
-
tooltip.style.top = `${event.clientY - 8}px`;
|
|
1487
|
-
tooltip.innerHTML = `
|
|
1488
|
-
<strong>${escapeHtml(node.title)}</strong><br>
|
|
1489
|
-
${escapeHtml(typeLabel(node.type))} · importance ${escapeHtml(formatMetric((node.importance_norm || 0) * 100, 0))}%<br>
|
|
1490
|
-
${node.type === 'Topic'
|
|
1491
|
-
? `mentions ${escapeHtml(formatMetric(metrics.mention_count || 0, 0))} · conversations ${escapeHtml(formatMetric(metrics.conversation_count || 0, 0))}`
|
|
1492
|
-
: `connections ${escapeHtml(formatMetric(metrics.degree || node.degree || 0, 0))}`
|
|
1493
|
-
}
|
|
1494
|
-
`;
|
|
1495
|
-
} else {
|
|
1496
|
-
tooltip.style.display = 'none';
|
|
1497
|
-
}
|
|
1498
|
-
});
|
|
1499
|
-
|
|
1500
|
-
canvas.addEventListener('mouseleave', () => {
|
|
1501
|
-
hovered = null;
|
|
1502
|
-
tooltip.style.display = 'none';
|
|
1503
|
-
wakeUp();
|
|
1504
|
-
});
|
|
1505
|
-
|
|
1506
|
-
window.addEventListener('mousemove', event => {
|
|
1507
|
-
if (dragging) {
|
|
1508
|
-
const rect = canvas.getBoundingClientRect();
|
|
1509
|
-
const world = toWorld(event.clientX - rect.left, event.clientY - rect.top);
|
|
1510
|
-
dragging.x = world.x;
|
|
1511
|
-
dragging.y = world.y;
|
|
1512
|
-
dragging.vx = 0;
|
|
1513
|
-
dragging.vy = 0;
|
|
1514
|
-
wakeUp();
|
|
1515
|
-
} else if (panning) {
|
|
1516
|
-
cam.tx = panning.tx0 + (event.clientX - panning.sx);
|
|
1517
|
-
cam.ty = panning.ty0 + (event.clientY - panning.sy);
|
|
1518
|
-
wakeUp();
|
|
1519
|
-
}
|
|
1520
|
-
});
|
|
1521
|
-
|
|
1522
|
-
window.addEventListener('mouseup', () => {
|
|
1523
|
-
dragging = null;
|
|
1524
|
-
panning = null;
|
|
1525
|
-
canvas.classList.remove('panning');
|
|
1526
|
-
});
|
|
1527
|
-
|
|
1528
|
-
canvas.addEventListener('wheel', event => {
|
|
1529
|
-
event.preventDefault();
|
|
1530
|
-
const rect = canvas.getBoundingClientRect();
|
|
1531
|
-
const canvasX = event.clientX - rect.left;
|
|
1532
|
-
const canvasY = event.clientY - rect.top;
|
|
1533
|
-
const zoomFactor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
|
|
1534
|
-
const nextScale = clamp(cam.scale * zoomFactor, 0.07, 6);
|
|
1535
|
-
cam.tx = canvasX - (canvasX - cam.tx) * (nextScale / cam.scale);
|
|
1536
|
-
cam.ty = canvasY - (canvasY - cam.ty) * (nextScale / cam.scale);
|
|
1537
|
-
cam.scale = nextScale;
|
|
1538
|
-
wakeUp();
|
|
1539
|
-
}, { passive: false });
|
|
1540
|
-
|
|
1541
|
-
canvas.addEventListener('dblclick', event => {
|
|
1542
|
-
const rect = canvas.getBoundingClientRect();
|
|
1543
|
-
const node = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
|
|
1544
|
-
if (node) expandNode(node).catch(error => {
|
|
1545
|
-
searchCountEl.textContent = error.message;
|
|
1546
|
-
});
|
|
1547
|
-
});
|
|
1548
|
-
|
|
1549
|
-
let lastTouchDistance = null;
|
|
1550
|
-
canvas.addEventListener('touchstart', event => {
|
|
1551
|
-
event.preventDefault();
|
|
1552
|
-
if (event.touches.length === 2) {
|
|
1553
|
-
lastTouchDistance = Math.hypot(
|
|
1554
|
-
event.touches[0].clientX - event.touches[1].clientX,
|
|
1555
|
-
event.touches[0].clientY - event.touches[1].clientY
|
|
1556
|
-
);
|
|
1557
|
-
dragging = null;
|
|
1558
|
-
return;
|
|
1559
|
-
}
|
|
1560
|
-
const touch = event.touches[0];
|
|
1561
|
-
const rect = canvas.getBoundingClientRect();
|
|
1562
|
-
const node = nodeAt(touch.clientX - rect.left, touch.clientY - rect.top);
|
|
1563
|
-
if (node) {
|
|
1564
|
-
dragging = node;
|
|
1565
|
-
showDetail(node);
|
|
1566
|
-
} else {
|
|
1567
|
-
panning = { sx: touch.clientX, sy: touch.clientY, tx0: cam.tx, ty0: cam.ty };
|
|
1568
|
-
}
|
|
1569
|
-
wakeUp();
|
|
1570
|
-
}, { passive: false });
|
|
1571
|
-
|
|
1572
|
-
canvas.addEventListener('touchmove', event => {
|
|
1573
|
-
event.preventDefault();
|
|
1574
|
-
if (event.touches.length === 2) {
|
|
1575
|
-
const distance = Math.hypot(
|
|
1576
|
-
event.touches[0].clientX - event.touches[1].clientX,
|
|
1577
|
-
event.touches[0].clientY - event.touches[1].clientY
|
|
1578
|
-
);
|
|
1579
|
-
if (lastTouchDistance) {
|
|
1580
|
-
const factor = distance / lastTouchDistance;
|
|
1581
|
-
const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
|
|
1582
|
-
const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
|
|
1583
|
-
const rect = canvas.getBoundingClientRect();
|
|
1584
|
-
const px = centerX - rect.left;
|
|
1585
|
-
const py = centerY - rect.top;
|
|
1586
|
-
const nextScale = clamp(cam.scale * factor, 0.07, 6);
|
|
1587
|
-
cam.tx = px - (px - cam.tx) * (nextScale / cam.scale);
|
|
1588
|
-
cam.ty = py - (py - cam.ty) * (nextScale / cam.scale);
|
|
1589
|
-
cam.scale = nextScale;
|
|
1590
|
-
wakeUp();
|
|
1591
|
-
}
|
|
1592
|
-
lastTouchDistance = distance;
|
|
1593
|
-
return;
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
const touch = event.touches[0];
|
|
1597
|
-
if (dragging) {
|
|
1598
|
-
const rect = canvas.getBoundingClientRect();
|
|
1599
|
-
const world = toWorld(touch.clientX - rect.left, touch.clientY - rect.top);
|
|
1600
|
-
dragging.x = world.x;
|
|
1601
|
-
dragging.y = world.y;
|
|
1602
|
-
dragging.vx = 0;
|
|
1603
|
-
dragging.vy = 0;
|
|
1604
|
-
} else if (panning) {
|
|
1605
|
-
cam.tx = panning.tx0 + (touch.clientX - panning.sx);
|
|
1606
|
-
cam.ty = panning.ty0 + (touch.clientY - panning.sy);
|
|
1607
|
-
}
|
|
1608
|
-
wakeUp();
|
|
1609
|
-
}, { passive: false });
|
|
1610
|
-
|
|
1611
|
-
canvas.addEventListener('touchend', () => {
|
|
1612
|
-
dragging = null;
|
|
1613
|
-
panning = null;
|
|
1614
|
-
lastTouchDistance = null;
|
|
1615
|
-
});
|
|
1616
|
-
|
|
1617
|
-
searchInput.addEventListener('input', scheduleSearch);
|
|
1618
|
-
searchInput.addEventListener('keydown', event => {
|
|
1619
|
-
if (event.key === 'Enter' && searchResults.length) {
|
|
1620
|
-
event.preventDefault();
|
|
1621
|
-
focusSearchResult(searchResults[0]).catch(error => {
|
|
1622
|
-
searchCountEl.textContent = t('error');
|
|
1623
|
-
searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
|
|
1624
|
-
});
|
|
1625
|
-
}
|
|
1626
|
-
});
|
|
1627
|
-
|
|
1628
|
-
document.getElementById('clear-search-btn').addEventListener('click', clearSearch);
|
|
1629
|
-
document.getElementById('fit-btn').addEventListener('click', fitToScreen);
|
|
1630
|
-
document.getElementById('expand-btn').addEventListener('click', () => expandNode().catch(error => { searchCountEl.textContent = error.message; }));
|
|
1631
|
-
document.getElementById('collapse-btn').addEventListener('click', () => collapseNode());
|
|
1632
|
-
document.getElementById('focus-btn').addEventListener('click', () => toggleFocus());
|
|
1633
|
-
document.getElementById('path-btn').addEventListener('click', () => showShortestPath().catch(error => { searchCountEl.textContent = error.message; }));
|
|
1634
|
-
document.addEventListener('click', event => {
|
|
1635
|
-
if (!event.target.closest('.lang-picker')) {
|
|
1636
|
-
document.querySelectorAll('.lang-picker-menu').forEach(menu => menu.classList.remove('open'));
|
|
1637
|
-
}
|
|
1638
|
-
});
|
|
1639
|
-
document.getElementById('refresh-btn').addEventListener('click', () => {
|
|
1640
|
-
rawGraph = { nodes: [], edges: [] };
|
|
1641
|
-
graph = { nodes: [], edges: [] };
|
|
1642
|
-
selected = null;
|
|
1643
|
-
focusNodeId = null;
|
|
1644
|
-
pathStartId = null;
|
|
1645
|
-
pathNodeIds = new Set();
|
|
1646
|
-
pathEdgeKeys = new Set();
|
|
1647
|
-
hiddenNodeIds = new Set();
|
|
1648
|
-
loadGraph().catch(error => {
|
|
1649
|
-
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>`;
|
|
1650
|
-
});
|
|
1651
|
-
});
|
|
1652
|
-
|
|
1653
|
-
searchResultsEl.addEventListener('click', event => {
|
|
1654
|
-
const target = event.target.closest('[data-node-id]');
|
|
1655
|
-
if (!target) return;
|
|
1656
|
-
const match = searchResults.find(item => item.id === target.dataset.nodeId);
|
|
1657
|
-
if (!match) return;
|
|
1658
|
-
focusSearchResult(match).catch(error => {
|
|
1659
|
-
searchCountEl.textContent = t('error');
|
|
1660
|
-
searchResultsEl.innerHTML = `<p class="search-empty">${escapeHtml(error.message)}</p>`;
|
|
1661
|
-
});
|
|
1662
|
-
});
|
|
1663
|
-
|
|
1664
|
-
// 리사이즈/회전/키보드(visualViewport) 시 캔버스 재측정 + 자동 재맞춤
|
|
1665
|
-
// (기존엔 backing store만 리사이즈해서 모바일에서 그래프가 화면 밖으로 나갔음)
|
|
1666
|
-
let resizeFitTimer = null;
|
|
1667
|
-
function handleViewportChange() {
|
|
1668
|
-
resize();
|
|
1669
|
-
wakeUp();
|
|
1670
|
-
clearTimeout(resizeFitTimer);
|
|
1671
|
-
resizeFitTimer = setTimeout(() => { resize(); fitToScreen(); }, 180);
|
|
1672
|
-
}
|
|
1673
|
-
window.addEventListener('resize', handleViewportChange);
|
|
1674
|
-
window.addEventListener('orientationchange', handleViewportChange);
|
|
1675
|
-
if (window.visualViewport) {
|
|
1676
|
-
window.visualViewport.addEventListener('resize', handleViewportChange);
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
/* ──────────────────────────────────────────────────────────────────
|
|
1680
|
-
v2.2.1 그래프 1급 UI: 줌 버튼 · 전체화면 · 미니맵 · 카드뷰 · 테마대응
|
|
1681
|
-
────────────────────────────────────────────────────────────────── */
|
|
1682
|
-
// 캔버스가 터치를 직접 소유 (브라우저 기본 제스처와 충돌 방지)
|
|
1683
|
-
if (canvas && canvas.style) canvas.style.touchAction = 'none';
|
|
1684
|
-
|
|
1685
|
-
function zoomBy(factor) {
|
|
1686
|
-
const px = width / 2, py = height / 2;
|
|
1687
|
-
const next = clamp(cam.scale * factor, 0.07, 6);
|
|
1688
|
-
cam.tx = px - (px - cam.tx) * (next / cam.scale);
|
|
1689
|
-
cam.ty = py - (py - cam.ty) * (next / cam.scale);
|
|
1690
|
-
cam.scale = next;
|
|
1691
|
-
wakeUp();
|
|
1692
|
-
}
|
|
1693
|
-
|
|
1694
|
-
const stageEl = document.querySelector('.stage');
|
|
1695
|
-
function toggleFullscreen() {
|
|
1696
|
-
const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
|
|
1697
|
-
if (!fsEl && stageEl) {
|
|
1698
|
-
(stageEl.requestFullscreen || stageEl.webkitRequestFullscreen || function () {}).call(stageEl);
|
|
1699
|
-
} else {
|
|
1700
|
-
(document.exitFullscreen || document.webkitExitFullscreen || function () {}).call(document);
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
document.addEventListener('fullscreenchange', handleViewportChange);
|
|
1704
|
-
document.addEventListener('webkitfullscreenchange', handleViewportChange);
|
|
1705
|
-
|
|
1706
|
-
// 미니맵 — 전체 노드 개요 + 현재 뷰포트 사각형 (클릭 시 그 지점으로 이동)
|
|
1707
|
-
const minimap = document.getElementById('minimap');
|
|
1708
|
-
const mmCtx = minimap ? minimap.getContext('2d') : null;
|
|
1709
|
-
function drawMinimap() {
|
|
1710
|
-
if (!mmCtx || !minimap || minimap.offsetParent === null) return;
|
|
1711
|
-
const W = minimap.width, H = minimap.height;
|
|
1712
|
-
mmCtx.clearRect(0, 0, W, H);
|
|
1713
|
-
if (!graph.nodes.length) return;
|
|
1714
|
-
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
1715
|
-
graph.nodes.forEach(n => { x0 = Math.min(x0, n.x); x1 = Math.max(x1, n.x); y0 = Math.min(y0, n.y); y1 = Math.max(y1, n.y); });
|
|
1716
|
-
const pad = 8, gw = Math.max(1, x1 - x0), gh = Math.max(1, y1 - y0);
|
|
1717
|
-
const s = Math.min((W - pad * 2) / gw, (H - pad * 2) / gh);
|
|
1718
|
-
const ox = pad - x0 * s + (W - pad * 2 - gw * s) / 2;
|
|
1719
|
-
const oy = pad - y0 * s + (H - pad * 2 - gh * s) / 2;
|
|
1720
|
-
graph.nodes.forEach(n => {
|
|
1721
|
-
mmCtx.fillStyle = nodeColor(n.type);
|
|
1722
|
-
mmCtx.beginPath();
|
|
1723
|
-
mmCtx.arc(ox + n.x * s, oy + n.y * s, 1.6, 0, Math.PI * 2);
|
|
1724
|
-
mmCtx.fill();
|
|
1725
|
-
});
|
|
1726
|
-
const vx0 = (0 - cam.tx) / cam.scale, vy0 = (0 - cam.ty) / cam.scale;
|
|
1727
|
-
const vx1 = (width - cam.tx) / cam.scale, vy1 = (height - cam.ty) / cam.scale;
|
|
1728
|
-
mmCtx.strokeStyle = 'rgba(110,74,230,0.95)';
|
|
1729
|
-
mmCtx.lineWidth = 1.2;
|
|
1730
|
-
mmCtx.strokeRect(ox + vx0 * s, oy + vy0 * s, (vx1 - vx0) * s, (vy1 - vy0) * s);
|
|
1731
|
-
minimap._map = { ox, oy, s };
|
|
1732
|
-
}
|
|
1733
|
-
if (minimap) {
|
|
1734
|
-
minimap.addEventListener('click', (event) => {
|
|
1735
|
-
const m = minimap._map; if (!m) return;
|
|
1736
|
-
const rect = minimap.getBoundingClientRect();
|
|
1737
|
-
const mx = (event.clientX - rect.left) * (minimap.width / rect.width);
|
|
1738
|
-
const my = (event.clientY - rect.top) * (minimap.height / rect.height);
|
|
1739
|
-
cam.tx = width / 2 - ((mx - m.ox) / m.s) * cam.scale;
|
|
1740
|
-
cam.ty = height / 2 - ((my - m.oy) / m.s) * cam.scale;
|
|
1741
|
-
wakeUp();
|
|
1742
|
-
});
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
// 모바일 카드 뷰 — 노드를 탭 가능한 카드 목록으로 (캔버스가 너무 빽빽할 때)
|
|
1746
|
-
const graphCardList = document.getElementById('graph-card-list');
|
|
1747
|
-
function renderGraphCards() {
|
|
1748
|
-
if (!graphCardList) return;
|
|
1749
|
-
if (!graph.nodes.length) {
|
|
1750
|
-
graphCardList.innerHTML = `<p class="search-empty">${t('search_empty')}</p>`;
|
|
1751
|
-
return;
|
|
1752
|
-
}
|
|
1753
|
-
graphCardList.innerHTML = '<div class="search-list">' + graph.nodes.slice(0, 400).map(n => `
|
|
1754
|
-
<button class="search-item" data-node-id="${escapeHtml(n.id)}">
|
|
1755
|
-
<div class="search-item-top">
|
|
1756
|
-
<span class="type-badge" style="background:${nodeColor(n.type)}">${escapeHtml(n.type || '')}</span>
|
|
1757
|
-
<span class="search-item-title">${escapeHtml(n.title || n.id)}</span>
|
|
1758
|
-
</div>
|
|
1759
|
-
${n.summary ? `<p class="search-item-summary">${escapeHtml(n.summary)}</p>` : ''}
|
|
1760
|
-
</button>
|
|
1761
|
-
`).join('') + '</div>';
|
|
1762
|
-
}
|
|
1763
|
-
function toggleGraphCardView() {
|
|
1764
|
-
document.body.classList.toggle('graph-card-view');
|
|
1765
|
-
if (document.body.classList.contains('graph-card-view')) renderGraphCards();
|
|
1766
|
-
}
|
|
1767
|
-
if (graphCardList) {
|
|
1768
|
-
graphCardList.addEventListener('click', (event) => {
|
|
1769
|
-
const target = event.target.closest('[data-node-id]');
|
|
1770
|
-
if (!target) return;
|
|
1771
|
-
const node = graph.nodes.find(n => n.id === target.dataset.nodeId);
|
|
1772
|
-
if (!node) return;
|
|
1773
|
-
document.body.classList.remove('graph-card-view');
|
|
1774
|
-
selected = node;
|
|
1775
|
-
showDetail(node);
|
|
1776
|
-
centerOnNode(node, Math.max(cam.scale, 1));
|
|
1777
|
-
});
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
// 테마(라이트/다크) 변경 시 캔버스 색상 갱신
|
|
1781
|
-
refreshThemeColors();
|
|
1782
|
-
try {
|
|
1783
|
-
const themeObserver = new MutationObserver(() => { refreshThemeColors(); wakeUp(); });
|
|
1784
|
-
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-lt-theme'] });
|
|
1785
|
-
} catch (e) { /* noop */ }
|
|
1786
|
-
|
|
1787
|
-
const bindClick = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('click', fn); };
|
|
1788
|
-
bindClick('zoom-in-btn', () => zoomBy(1.25));
|
|
1789
|
-
bindClick('zoom-out-btn', () => zoomBy(1 / 1.25));
|
|
1790
|
-
bindClick('fullscreen-btn', toggleFullscreen);
|
|
1791
|
-
bindClick('view-toggle-btn', toggleGraphCardView);
|
|
1792
|
-
|
|
1793
|
-
resize();
|
|
1794
|
-
applyI18n();
|
|
1795
|
-
renderSearchResults();
|
|
1796
|
-
renderLocalSources();
|
|
1797
|
-
loadLocalSources();
|
|
1798
|
-
loadGraph().catch(error => {
|
|
1799
|
-
detail.innerHTML = `
|
|
1800
|
-
<div class="type-badge" style="background:${nodeColor('ClearEvent')}">${t('error')}</div>
|
|
1801
|
-
<div class="detail-title">${t('graph_load_fail')}</div>
|
|
1802
|
-
<div class="detail-summary">${escapeHtml(error.message)}</div>
|
|
1803
|
-
`;
|
|
1804
|
-
});
|