SOY NV AI
commited on
Commit
·
9f9c12f
1
Parent(s):
bb30dc7
GraphRAG UI 개선 및 사용자 인터페이스 개선
Browse files- GraphRAG 보기에 좌측 사이드바 추가 (회차 목록 및 네비게이션)
- 회차 데이터를 숫자 순서로 정렬 (1화, 2화... 99화, 100화)
- 그래프 시각화 회차 필터를 드롭다운 형태로 변경 및 다중 선택 지원
- 그래프 시각화 초기 상태를 빈 화면으로 변경
- 뷰 리셋 기능 개선 및 안정성 향상
- webnovels 페이지의 GraphRAG 보기와 그래프 시각화를 admin_files와 동일하게 수정
- 원작 정보 버튼에 텍스트 추가 및 스타일 개선
- 우측 상단 메뉴 순서 변경 (닉네임 원작 정보)
- app/routes.py +22 -0
- templates/admin_files.html +391 -38
- templates/index.html +22 -3
- templates/webnovels.html +394 -38
app/routes.py
CHANGED
|
@@ -3476,6 +3476,28 @@ def delete_file(file_id):
|
|
| 3476 |
db.session.delete(parent_chunk)
|
| 3477 |
print(f"[파일 삭제] Parent Chunk 삭제 완료")
|
| 3478 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3479 |
deleted_files.append(file_to_delete.original_filename)
|
| 3480 |
db.session.delete(file_to_delete)
|
| 3481 |
deleted_count += 1
|
|
|
|
| 3476 |
db.session.delete(parent_chunk)
|
| 3477 |
print(f"[파일 삭제] Parent Chunk 삭제 완료")
|
| 3478 |
|
| 3479 |
+
# 관련 EpisodeAnalysis 삭제
|
| 3480 |
+
episode_analysis_count = EpisodeAnalysis.query.filter_by(file_id=file_to_delete.id).count()
|
| 3481 |
+
if episode_analysis_count > 0:
|
| 3482 |
+
EpisodeAnalysis.query.filter_by(file_id=file_to_delete.id).delete()
|
| 3483 |
+
print(f"[파일 삭제] EpisodeAnalysis {episode_analysis_count}개 삭제 완료")
|
| 3484 |
+
|
| 3485 |
+
# 관련 GraphRAG 데이터 삭제 (GraphEntity, GraphRelationship, GraphEvent)
|
| 3486 |
+
graph_entity_count = GraphEntity.query.filter_by(file_id=file_to_delete.id).count()
|
| 3487 |
+
if graph_entity_count > 0:
|
| 3488 |
+
GraphEntity.query.filter_by(file_id=file_to_delete.id).delete()
|
| 3489 |
+
print(f"[파일 삭제] GraphEntity {graph_entity_count}개 삭제 완료")
|
| 3490 |
+
|
| 3491 |
+
graph_relationship_count = GraphRelationship.query.filter_by(file_id=file_to_delete.id).count()
|
| 3492 |
+
if graph_relationship_count > 0:
|
| 3493 |
+
GraphRelationship.query.filter_by(file_id=file_to_delete.id).delete()
|
| 3494 |
+
print(f"[파일 삭제] GraphRelationship {graph_relationship_count}개 삭제 완료")
|
| 3495 |
+
|
| 3496 |
+
graph_event_count = GraphEvent.query.filter_by(file_id=file_to_delete.id).count()
|
| 3497 |
+
if graph_event_count > 0:
|
| 3498 |
+
GraphEvent.query.filter_by(file_id=file_to_delete.id).delete()
|
| 3499 |
+
print(f"[파일 삭제] GraphEvent {graph_event_count}개 삭제 완료")
|
| 3500 |
+
|
| 3501 |
deleted_files.append(file_to_delete.original_filename)
|
| 3502 |
db.session.delete(file_to_delete)
|
| 3503 |
deleted_count += 1
|
templates/admin_files.html
CHANGED
|
@@ -310,6 +310,33 @@
|
|
| 310 |
font-size: 14px;
|
| 311 |
background: white;
|
| 312 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
</style>
|
| 314 |
</head>
|
| 315 |
<body>
|
|
@@ -415,14 +442,28 @@
|
|
| 415 |
|
| 416 |
<!-- GraphRAG 모달 -->
|
| 417 |
<div id="graphRAGModal" class="modal">
|
| 418 |
-
<div class="modal-content" style="max-width:
|
| 419 |
-
<div class="modal-header">
|
| 420 |
<div class="modal-title" id="graphRAGModalTitle">GraphRAG 데이터</div>
|
| 421 |
<button class="modal-close" onclick="closeGraphRAGModal()">×</button>
|
| 422 |
</div>
|
| 423 |
-
<div
|
| 424 |
-
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
</div>
|
| 427 |
</div>
|
| 428 |
</div>
|
|
@@ -436,10 +477,23 @@
|
|
| 436 |
<button class="modal-close" onclick="closeGraphRAGVisualizationModal()">×</button>
|
| 437 |
</div>
|
| 438 |
<div style="padding: 16px; border-bottom: 1px solid #dadce0; background: #f8f9fa; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
|
| 439 |
-
<
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
<label style="font-size: 14px; font-weight: 500; margin-left: 16px;">노드 타입:</label>
|
| 444 |
<label style="font-size: 13px; margin-left: 8px;">
|
| 445 |
<input type="checkbox" id="showCharacters" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
|
|
@@ -766,13 +820,25 @@
|
|
| 766 |
document.getElementById('chunkContentModal').classList.remove('active');
|
| 767 |
}
|
| 768 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 769 |
async function viewGraphRAG(fileId, fileName) {
|
| 770 |
const modal = document.getElementById('graphRAGModal');
|
| 771 |
const title = document.getElementById('graphRAGModalTitle');
|
| 772 |
const content = document.getElementById('graphRAGContent');
|
|
|
|
| 773 |
|
| 774 |
title.textContent = `GraphRAG 데이터 - ${fileName}`;
|
| 775 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368;">GraphRAG 데이터를 불러오는 중...</div>';
|
|
|
|
| 776 |
modal.classList.add('active');
|
| 777 |
|
| 778 |
try {
|
|
@@ -783,6 +849,27 @@
|
|
| 783 |
|
| 784 |
const data = await response.json();
|
| 785 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
let contentHtml = '';
|
| 787 |
|
| 788 |
// 통계 정보
|
|
@@ -798,14 +885,12 @@
|
|
| 798 |
contentHtml += '</div>';
|
| 799 |
}
|
| 800 |
|
| 801 |
-
// 회차별 데이터 표시
|
| 802 |
-
const episodes = data.episodes || [];
|
| 803 |
-
|
| 804 |
if (episodes.length === 0) {
|
| 805 |
contentHtml += '<div style="text-align: center; padding: 24px; color: #5f6368;">GraphRAG 데이터가 없습니다.</div>';
|
| 806 |
} else {
|
| 807 |
episodes.forEach(episode => {
|
| 808 |
-
|
|
|
|
| 809 |
contentHtml += `<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 20px; color: #1a73e8;">${escapeHtml(episode)}</h3>`;
|
| 810 |
|
| 811 |
// 엔티티 (인물)
|
|
@@ -912,6 +997,13 @@
|
|
| 912 |
}
|
| 913 |
|
| 914 |
content.innerHTML = contentHtml;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
} catch (error) {
|
| 916 |
console.error('GraphRAG 데이터 로드 오류:', error);
|
| 917 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #c5221f;">GraphRAG 데이터를 불러오는 중 오류가 발생했습니다.</div>';
|
|
@@ -919,6 +1011,78 @@
|
|
| 919 |
}
|
| 920 |
}
|
| 921 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
function closeGraphRAGModal() {
|
| 923 |
document.getElementById('graphRAGModal').classList.remove('active');
|
| 924 |
}
|
|
@@ -934,7 +1098,7 @@
|
|
| 934 |
const content = document.getElementById('graphRAGVisualizationContent');
|
| 935 |
|
| 936 |
title.textContent = `GraphRAG 그래프 시각화 - ${fileName}`;
|
| 937 |
-
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);"
|
| 938 |
modal.classList.add('active');
|
| 939 |
|
| 940 |
// 기존 네트워크 제거
|
|
@@ -943,6 +1107,11 @@
|
|
| 943 |
graphNetwork = null;
|
| 944 |
}
|
| 945 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 946 |
try {
|
| 947 |
const response = await fetch(`/api/files/${fileId}/graph`, {
|
| 948 |
credentials: 'include'
|
|
@@ -952,20 +1121,43 @@
|
|
| 952 |
const data = await response.json();
|
| 953 |
allGraphData = data;
|
| 954 |
|
| 955 |
-
// 회차 필터
|
| 956 |
-
const
|
| 957 |
-
|
|
|
|
|
|
|
|
|
|
| 958 |
if (data.episodes && data.episodes.length > 0) {
|
| 959 |
-
data.episodes
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 964 |
});
|
| 965 |
}
|
| 966 |
|
| 967 |
-
//
|
| 968 |
-
|
|
|
|
|
|
|
|
|
|
| 969 |
} catch (error) {
|
| 970 |
console.error('GraphRAG 그래프 로드 오류:', error);
|
| 971 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #c5221f; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">그래프를 불러오는 중 오류가 발생했습니다.</div>';
|
|
@@ -974,6 +1166,23 @@
|
|
| 974 |
|
| 975 |
function createGraphVisualization(data, episodeFilter = 'all') {
|
| 976 |
const content = document.getElementById('graphRAGVisualizationContent');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 977 |
content.innerHTML = ''; // 기존 내용 제거
|
| 978 |
|
| 979 |
// 노드와 엣지 데이터 생성
|
|
@@ -983,14 +1192,10 @@
|
|
| 983 |
const nodeMap = new Map(); // 노드 ID 매핑
|
| 984 |
let nodeIdCounter = 1;
|
| 985 |
|
| 986 |
-
|
| 987 |
-
const showLocations = document.getElementById('showLocations').checked;
|
| 988 |
-
const showEvents = document.getElementById('showEvents').checked;
|
| 989 |
-
|
| 990 |
-
// 필터링할 회차 목록
|
| 991 |
const episodes = episodeFilter === 'all'
|
| 992 |
? (data.episodes || [])
|
| 993 |
-
: [episodeFilter];
|
| 994 |
|
| 995 |
// 엔티티 추가 (인물, 장소)
|
| 996 |
episodes.forEach(episode => {
|
|
@@ -1240,12 +1445,137 @@
|
|
| 1240 |
}
|
| 1241 |
}
|
| 1242 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1243 |
}
|
| 1244 |
|
| 1245 |
function updateGraphVisualization() {
|
| 1246 |
if (!allGraphData) return;
|
| 1247 |
|
| 1248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1249 |
|
| 1250 |
// 기존 네트워크 제거
|
| 1251 |
if (graphNetwork) {
|
|
@@ -1254,17 +1584,40 @@
|
|
| 1254 |
}
|
| 1255 |
|
| 1256 |
// 새 그래프 생성
|
| 1257 |
-
createGraphVisualization(allGraphData,
|
| 1258 |
}
|
| 1259 |
|
| 1260 |
function resetGraphView() {
|
| 1261 |
-
if (graphNetwork) {
|
| 1262 |
-
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1266 |
}
|
| 1267 |
-
})
|
|
|
|
|
|
|
| 1268 |
}
|
| 1269 |
}
|
| 1270 |
|
|
|
|
| 310 |
font-size: 14px;
|
| 311 |
background: white;
|
| 312 |
}
|
| 313 |
+
|
| 314 |
+
/* GraphRAG 사이드바 스타일 */
|
| 315 |
+
.episode-sidebar-item {
|
| 316 |
+
padding: 10px 12px;
|
| 317 |
+
border-radius: 6px;
|
| 318 |
+
cursor: pointer;
|
| 319 |
+
transition: all 0.2s;
|
| 320 |
+
font-size: 14px;
|
| 321 |
+
color: #202124;
|
| 322 |
+
margin-bottom: 4px;
|
| 323 |
+
border: 1px solid transparent;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.episode-sidebar-item:hover {
|
| 327 |
+
background: #e8f0fe;
|
| 328 |
+
border-color: #1a73e8;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.episode-sidebar-item.active {
|
| 332 |
+
background: #1a73e8;
|
| 333 |
+
color: white;
|
| 334 |
+
font-weight: 500;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.episode-sidebar-item.active:hover {
|
| 338 |
+
background: #1557b0;
|
| 339 |
+
}
|
| 340 |
</style>
|
| 341 |
</head>
|
| 342 |
<body>
|
|
|
|
| 442 |
|
| 443 |
<!-- GraphRAG 모달 -->
|
| 444 |
<div id="graphRAGModal" class="modal">
|
| 445 |
+
<div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh; display: flex; flex-direction: column; padding: 0;">
|
| 446 |
+
<div class="modal-header" style="flex-shrink: 0; padding: 24px 24px 16px 24px; margin-bottom: 0;">
|
| 447 |
<div class="modal-title" id="graphRAGModalTitle">GraphRAG 데이터</div>
|
| 448 |
<button class="modal-close" onclick="closeGraphRAGModal()">×</button>
|
| 449 |
</div>
|
| 450 |
+
<div style="display: flex; flex: 1; overflow: hidden;">
|
| 451 |
+
<!-- 좌측 사이드바 (회차 목록) -->
|
| 452 |
+
<div id="graphRAGSidebar" style="width: 250px; background: #f8f9fa; border-right: 1px solid #e8eaed; overflow-y: auto; flex-shrink: 0; padding: 16px;">
|
| 453 |
+
<div style="font-size: 14px; font-weight: 600; color: #5f6368; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #e8eaed;">
|
| 454 |
+
회차 목록
|
| 455 |
+
</div>
|
| 456 |
+
<div id="graphRAGEpisodeList" style="display: flex; flex-direction: column; gap: 4px;">
|
| 457 |
+
<div style="text-align: center; padding: 24px; color: #5f6368; font-size: 13px;">
|
| 458 |
+
회차 목록을 불러오는 중...
|
| 459 |
+
</div>
|
| 460 |
+
</div>
|
| 461 |
+
</div>
|
| 462 |
+
<!-- 우측 콘텐츠 영역 -->
|
| 463 |
+
<div id="graphRAGContent" style="flex: 1; overflow-y: auto; padding: 24px;">
|
| 464 |
+
<div style="text-align: center; padding: 24px; color: #5f6368;">
|
| 465 |
+
GraphRAG 데이터를 불러오는 중...
|
| 466 |
+
</div>
|
| 467 |
</div>
|
| 468 |
</div>
|
| 469 |
</div>
|
|
|
|
| 477 |
<button class="modal-close" onclick="closeGraphRAGVisualizationModal()">×</button>
|
| 478 |
</div>
|
| 479 |
<div style="padding: 16px; border-bottom: 1px solid #dadce0; background: #f8f9fa; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
|
| 480 |
+
<div style="position: relative;">
|
| 481 |
+
<button id="episodeFilterToggle" onclick="toggleEpisodeFilter()" style="padding: 8px 16px; background: white; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; color: #202124; font-weight: 500;">
|
| 482 |
+
<span>회차 필터</span>
|
| 483 |
+
<span id="episodeFilterToggleIcon" style="font-size: 12px; transition: transform 0.2s;">▼</span>
|
| 484 |
+
</button>
|
| 485 |
+
<div id="episodeFilterDropdown" style="display: none; position: absolute; top: 100%; left: 0; margin-top: 4px; background: white; border: 1px solid #dadce0; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 1000; min-width: 250px; max-width: 400px; max-height: 400px; overflow-y: auto;">
|
| 486 |
+
<div style="padding: 12px; border-bottom: 1px solid #e8eaed;">
|
| 487 |
+
<label style="font-size: 13px; cursor: pointer; padding: 6px 8px; border-radius: 4px; display: flex; align-items: center; transition: background 0.2s;" onmouseover="this.style.background='#f8f9fa'" onmouseout="this.style.background='transparent'">
|
| 488 |
+
<input type="checkbox" id="episodeFilterAll" onchange="handleEpisodeFilterAllChange()" style="margin-right: 8px;">
|
| 489 |
+
<span style="font-weight: 500;">전체 회차</span>
|
| 490 |
+
</label>
|
| 491 |
+
</div>
|
| 492 |
+
<div id="episodeFilterList" style="padding: 8px; display: flex; flex-direction: column; gap: 2px;">
|
| 493 |
+
<!-- 회차 체크박스 목록이 여기에 동적으로 추가됩니다 -->
|
| 494 |
+
</div>
|
| 495 |
+
</div>
|
| 496 |
+
</div>
|
| 497 |
<label style="font-size: 14px; font-weight: 500; margin-left: 16px;">노드 타입:</label>
|
| 498 |
<label style="font-size: 13px; margin-left: 8px;">
|
| 499 |
<input type="checkbox" id="showCharacters" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
|
|
|
|
| 820 |
document.getElementById('chunkContentModal').classList.remove('active');
|
| 821 |
}
|
| 822 |
|
| 823 |
+
// 회차를 숫자 순서로 정렬하는 함수 (1화, 2화... 99화, 100화, 101화)
|
| 824 |
+
function sortEpisodesByNumber(episodes) {
|
| 825 |
+
return episodes.slice().sort((a, b) => {
|
| 826 |
+
// 숫자 추출 (예: "1화" -> 1, "100화" -> 100)
|
| 827 |
+
const numA = parseInt(a.match(/\d+/)?.[0] || '0');
|
| 828 |
+
const numB = parseInt(b.match(/\d+/)?.[0] || '0');
|
| 829 |
+
return numA - numB;
|
| 830 |
+
});
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
async function viewGraphRAG(fileId, fileName) {
|
| 834 |
const modal = document.getElementById('graphRAGModal');
|
| 835 |
const title = document.getElementById('graphRAGModalTitle');
|
| 836 |
const content = document.getElementById('graphRAGContent');
|
| 837 |
+
const sidebar = document.getElementById('graphRAGEpisodeList');
|
| 838 |
|
| 839 |
title.textContent = `GraphRAG 데이터 - ${fileName}`;
|
| 840 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368;">GraphRAG 데이터를 불러오는 중...</div>';
|
| 841 |
+
sidebar.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368; font-size: 13px;">회차 목록을 불러오는 중...</div>';
|
| 842 |
modal.classList.add('active');
|
| 843 |
|
| 844 |
try {
|
|
|
|
| 849 |
|
| 850 |
const data = await response.json();
|
| 851 |
|
| 852 |
+
// 회차별 데이터 표시 (숫자 순서로 정렬)
|
| 853 |
+
const episodes = sortEpisodesByNumber(data.episodes || []);
|
| 854 |
+
|
| 855 |
+
// 사이드바에 회차 목록 표시
|
| 856 |
+
sidebar.innerHTML = '';
|
| 857 |
+
if (episodes.length === 0) {
|
| 858 |
+
sidebar.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368; font-size: 13px;">회차가 없습니다.</div>';
|
| 859 |
+
} else {
|
| 860 |
+
episodes.forEach((episode, index) => {
|
| 861 |
+
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
| 862 |
+
const item = document.createElement('div');
|
| 863 |
+
item.className = 'episode-sidebar-item';
|
| 864 |
+
item.textContent = episode;
|
| 865 |
+
item.onclick = () => scrollToEpisode(episodeId);
|
| 866 |
+
if (index === 0) {
|
| 867 |
+
item.classList.add('active');
|
| 868 |
+
}
|
| 869 |
+
sidebar.appendChild(item);
|
| 870 |
+
});
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
let contentHtml = '';
|
| 874 |
|
| 875 |
// 통계 정보
|
|
|
|
| 885 |
contentHtml += '</div>';
|
| 886 |
}
|
| 887 |
|
|
|
|
|
|
|
|
|
|
| 888 |
if (episodes.length === 0) {
|
| 889 |
contentHtml += '<div style="text-align: center; padding: 24px; color: #5f6368;">GraphRAG 데이터가 없습니다.</div>';
|
| 890 |
} else {
|
| 891 |
episodes.forEach(episode => {
|
| 892 |
+
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
| 893 |
+
contentHtml += `<div id="${episodeId}" style="margin-bottom: 32px; padding: 20px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #1a73e8; scroll-margin-top: 20px;">`;
|
| 894 |
contentHtml += `<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 20px; color: #1a73e8;">${escapeHtml(episode)}</h3>`;
|
| 895 |
|
| 896 |
// 엔티티 (인물)
|
|
|
|
| 997 |
}
|
| 998 |
|
| 999 |
content.innerHTML = contentHtml;
|
| 1000 |
+
|
| 1001 |
+
// 스크롤 이벤트 리스너 추가 (현재 보이는 회차 하이라이트)
|
| 1002 |
+
const contentElement = document.getElementById('graphRAGContent');
|
| 1003 |
+
contentElement.addEventListener('scroll', () => updateActiveEpisode(episodes));
|
| 1004 |
+
|
| 1005 |
+
// 초기 활성 회차 설정
|
| 1006 |
+
updateActiveEpisode(episodes);
|
| 1007 |
} catch (error) {
|
| 1008 |
console.error('GraphRAG 데이터 로드 오류:', error);
|
| 1009 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #c5221f;">GraphRAG 데이터를 불러오는 중 오류가 발생했습니다.</div>';
|
|
|
|
| 1011 |
}
|
| 1012 |
}
|
| 1013 |
|
| 1014 |
+
// 특정 회차로 스크롤
|
| 1015 |
+
function scrollToEpisode(episodeId) {
|
| 1016 |
+
const element = document.getElementById(episodeId);
|
| 1017 |
+
if (element) {
|
| 1018 |
+
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 1019 |
+
|
| 1020 |
+
// 사이드바에서 활성 상태 업데이트
|
| 1021 |
+
const sidebarItems = document.querySelectorAll('.episode-sidebar-item');
|
| 1022 |
+
sidebarItems.forEach(item => item.classList.remove('active'));
|
| 1023 |
+
|
| 1024 |
+
// 클릭한 아이템 활성화
|
| 1025 |
+
const clickedItem = Array.from(sidebarItems).find(item => {
|
| 1026 |
+
const itemEpisodeId = `episode-${item.textContent.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
| 1027 |
+
return itemEpisodeId === episodeId;
|
| 1028 |
+
});
|
| 1029 |
+
if (clickedItem) {
|
| 1030 |
+
clickedItem.classList.add('active');
|
| 1031 |
+
}
|
| 1032 |
+
}
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
// 현재 보이는 회차를 하이라이트
|
| 1036 |
+
function updateActiveEpisode(episodes) {
|
| 1037 |
+
const contentElement = document.getElementById('graphRAGContent');
|
| 1038 |
+
const sidebarItems = document.querySelectorAll('.episode-sidebar-item');
|
| 1039 |
+
|
| 1040 |
+
if (!contentElement || sidebarItems.length === 0) return;
|
| 1041 |
+
|
| 1042 |
+
const scrollTop = contentElement.scrollTop;
|
| 1043 |
+
const viewportHeight = contentElement.clientHeight;
|
| 1044 |
+
const scrollBottom = scrollTop + viewportHeight;
|
| 1045 |
+
|
| 1046 |
+
// 통계 정보 섹션 높이 고려 (대략 150px)
|
| 1047 |
+
const statsOffset = 150;
|
| 1048 |
+
|
| 1049 |
+
let activeEpisode = null;
|
| 1050 |
+
let activeElement = null;
|
| 1051 |
+
|
| 1052 |
+
episodes.forEach(episode => {
|
| 1053 |
+
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
| 1054 |
+
const element = document.getElementById(episodeId);
|
| 1055 |
+
|
| 1056 |
+
if (element) {
|
| 1057 |
+
const elementTop = element.offsetTop - statsOffset;
|
| 1058 |
+
const elementBottom = elementTop + element.offsetHeight;
|
| 1059 |
+
|
| 1060 |
+
// 요소가 뷰포트 상단 근처에 있으면 활성화
|
| 1061 |
+
if (elementTop <= scrollTop + 100 && elementBottom > scrollTop) {
|
| 1062 |
+
activeEpisode = episode;
|
| 1063 |
+
activeElement = element;
|
| 1064 |
+
}
|
| 1065 |
+
}
|
| 1066 |
+
});
|
| 1067 |
+
|
| 1068 |
+
// 첫 번째 회차가 보이지 않으면 첫 번째 회차를 활성화
|
| 1069 |
+
if (!activeEpisode && episodes.length > 0) {
|
| 1070 |
+
const firstEpisode = episodes[0];
|
| 1071 |
+
const firstElement = document.getElementById(`episode-${firstEpisode.replace(/[^a-zA-Z0-9]/g, '-')}`);
|
| 1072 |
+
if (firstElement && firstElement.offsetTop - 150 > scrollTop + viewportHeight) {
|
| 1073 |
+
activeEpisode = firstEpisode;
|
| 1074 |
+
}
|
| 1075 |
+
}
|
| 1076 |
+
|
| 1077 |
+
// 사이드바 아이템 활성 상태 업데이트
|
| 1078 |
+
sidebarItems.forEach(item => {
|
| 1079 |
+
item.classList.remove('active');
|
| 1080 |
+
if (activeEpisode && item.textContent === activeEpisode) {
|
| 1081 |
+
item.classList.add('active');
|
| 1082 |
+
}
|
| 1083 |
+
});
|
| 1084 |
+
}
|
| 1085 |
+
|
| 1086 |
function closeGraphRAGModal() {
|
| 1087 |
document.getElementById('graphRAGModal').classList.remove('active');
|
| 1088 |
}
|
|
|
|
| 1098 |
const content = document.getElementById('graphRAGVisualizationContent');
|
| 1099 |
|
| 1100 |
title.textContent = `GraphRAG 그래프 시각화 - ${fileName}`;
|
| 1101 |
+
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">회차와 노드 타입을 선택하여 그래프를 확인하세요.</div>';
|
| 1102 |
modal.classList.add('active');
|
| 1103 |
|
| 1104 |
// 기존 네트워크 제거
|
|
|
|
| 1107 |
graphNetwork = null;
|
| 1108 |
}
|
| 1109 |
|
| 1110 |
+
// 체크박스 초기화
|
| 1111 |
+
document.getElementById('showCharacters').checked = false;
|
| 1112 |
+
document.getElementById('showLocations').checked = false;
|
| 1113 |
+
document.getElementById('showEvents').checked = false;
|
| 1114 |
+
|
| 1115 |
try {
|
| 1116 |
const response = await fetch(`/api/files/${fileId}/graph`, {
|
| 1117 |
credentials: 'include'
|
|
|
|
| 1121 |
const data = await response.json();
|
| 1122 |
allGraphData = data;
|
| 1123 |
|
| 1124 |
+
// 회차 필터 체크박스 생성 (숫자 순서로 정렬)
|
| 1125 |
+
const episodeFilterList = document.getElementById('episodeFilterList');
|
| 1126 |
+
episodeFilterList.innerHTML = '';
|
| 1127 |
+
const episodeFilterAll = document.getElementById('episodeFilterAll');
|
| 1128 |
+
episodeFilterAll.checked = false;
|
| 1129 |
+
|
| 1130 |
if (data.episodes && data.episodes.length > 0) {
|
| 1131 |
+
const sortedEpisodes = sortEpisodesByNumber(data.episodes);
|
| 1132 |
+
sortedEpisodes.forEach(episode => {
|
| 1133 |
+
const label = document.createElement('label');
|
| 1134 |
+
label.style.cssText = 'font-size: 13px; cursor: pointer; padding: 6px 12px; border-radius: 4px; display: flex; align-items: center; transition: background 0.2s;';
|
| 1135 |
+
label.onmouseover = function() { this.style.background = '#f8f9fa'; };
|
| 1136 |
+
label.onmouseout = function() { this.style.background = 'transparent'; };
|
| 1137 |
+
|
| 1138 |
+
const checkbox = document.createElement('input');
|
| 1139 |
+
checkbox.type = 'checkbox';
|
| 1140 |
+
checkbox.value = episode;
|
| 1141 |
+
checkbox.id = `episodeFilter_${episode.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
| 1142 |
+
checkbox.onchange = handleIndividualEpisodeChange;
|
| 1143 |
+
checkbox.style.marginRight = '8px';
|
| 1144 |
+
checkbox.style.cursor = 'pointer';
|
| 1145 |
+
|
| 1146 |
+
const span = document.createElement('span');
|
| 1147 |
+
span.textContent = episode;
|
| 1148 |
+
span.style.flex = '1';
|
| 1149 |
+
|
| 1150 |
+
label.appendChild(checkbox);
|
| 1151 |
+
label.appendChild(span);
|
| 1152 |
+
episodeFilterList.appendChild(label);
|
| 1153 |
});
|
| 1154 |
}
|
| 1155 |
|
| 1156 |
+
// 버튼 텍스트 초기화
|
| 1157 |
+
updateEpisodeFilterButtonText();
|
| 1158 |
+
|
| 1159 |
+
// 초기에는 그래프를 생성하지 않음 (빈 화면)
|
| 1160 |
+
// 사용자가 필터나 체크박스를 선택하면 그래프가 생성됨
|
| 1161 |
} catch (error) {
|
| 1162 |
console.error('GraphRAG 그래프 로드 오류:', error);
|
| 1163 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #c5221f; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">그래프를 불러오는 중 오류가 발생했습니다.</div>';
|
|
|
|
| 1166 |
|
| 1167 |
function createGraphVisualization(data, episodeFilter = 'all') {
|
| 1168 |
const content = document.getElementById('graphRAGVisualizationContent');
|
| 1169 |
+
|
| 1170 |
+
// 필터가 선택되지 않았거나 빈 배열인 경우 빈 화면 표시
|
| 1171 |
+
if (!episodeFilter || (Array.isArray(episodeFilter) && episodeFilter.length === 0)) {
|
| 1172 |
+
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">회차를 선택하여 그래프를 확인하세요.</div>';
|
| 1173 |
+
return;
|
| 1174 |
+
}
|
| 1175 |
+
|
| 1176 |
+
const showCharacters = document.getElementById('showCharacters').checked;
|
| 1177 |
+
const showLocations = document.getElementById('showLocations').checked;
|
| 1178 |
+
const showEvents = document.getElementById('showEvents').checked;
|
| 1179 |
+
|
| 1180 |
+
// 체크박스가 모두 해제된 경우 빈 화면 표시
|
| 1181 |
+
if (!showCharacters && !showLocations && !showEvents) {
|
| 1182 |
+
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">노드 타입(인물, 장소, 사건)을 하나 이상 선택하여 그래프를 확인하세요.</div>';
|
| 1183 |
+
return;
|
| 1184 |
+
}
|
| 1185 |
+
|
| 1186 |
content.innerHTML = ''; // 기존 내용 제거
|
| 1187 |
|
| 1188 |
// 노드와 엣지 데이터 생성
|
|
|
|
| 1192 |
const nodeMap = new Map(); // 노드 ID 매핑
|
| 1193 |
let nodeIdCounter = 1;
|
| 1194 |
|
| 1195 |
+
// 필터링할 회차 목록 (다중 선택 지원)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1196 |
const episodes = episodeFilter === 'all'
|
| 1197 |
? (data.episodes || [])
|
| 1198 |
+
: (Array.isArray(episodeFilter) ? episodeFilter : [episodeFilter]);
|
| 1199 |
|
| 1200 |
// 엔티티 추가 (인물, 장소)
|
| 1201 |
episodes.forEach(episode => {
|
|
|
|
| 1445 |
}
|
| 1446 |
}
|
| 1447 |
});
|
| 1448 |
+
|
| 1449 |
+
// stabilization 완료 후 자동으로 fit() 호출
|
| 1450 |
+
graphNetwork.on('stabilizationEnd', function() {
|
| 1451 |
+
try {
|
| 1452 |
+
graphNetwork.fit({
|
| 1453 |
+
animation: {
|
| 1454 |
+
duration: 500,
|
| 1455 |
+
easingFunction: 'easeInOutQuad'
|
| 1456 |
+
}
|
| 1457 |
+
});
|
| 1458 |
+
} catch (error) {
|
| 1459 |
+
console.error('자동 fit() 오류:', error);
|
| 1460 |
+
}
|
| 1461 |
+
});
|
| 1462 |
+
|
| 1463 |
+
// stabilization이 비활성화된 경우를 대비해 짧은 지연 후에도 fit() 호출
|
| 1464 |
+
setTimeout(() => {
|
| 1465 |
+
if (graphNetwork && nodes.length() > 0) {
|
| 1466 |
+
try {
|
| 1467 |
+
graphNetwork.fit({
|
| 1468 |
+
animation: {
|
| 1469 |
+
duration: 500,
|
| 1470 |
+
easingFunction: 'easeInOutQuad'
|
| 1471 |
+
}
|
| 1472 |
+
});
|
| 1473 |
+
} catch (error) {
|
| 1474 |
+
console.error('지연 fit() 오류:', error);
|
| 1475 |
+
}
|
| 1476 |
+
}
|
| 1477 |
+
}, 500);
|
| 1478 |
+
}
|
| 1479 |
+
|
| 1480 |
+
// 회차 필터 토글 함수
|
| 1481 |
+
function toggleEpisodeFilter() {
|
| 1482 |
+
const dropdown = document.getElementById('episodeFilterDropdown');
|
| 1483 |
+
const icon = document.getElementById('episodeFilterToggleIcon');
|
| 1484 |
+
const isVisible = dropdown.style.display !== 'none';
|
| 1485 |
+
|
| 1486 |
+
if (isVisible) {
|
| 1487 |
+
dropdown.style.display = 'none';
|
| 1488 |
+
icon.style.transform = 'rotate(0deg)';
|
| 1489 |
+
} else {
|
| 1490 |
+
dropdown.style.display = 'block';
|
| 1491 |
+
icon.style.transform = 'rotate(180deg)';
|
| 1492 |
+
}
|
| 1493 |
+
}
|
| 1494 |
+
|
| 1495 |
+
// 외부 클릭 시 회차 필터 드롭다운 닫기
|
| 1496 |
+
document.addEventListener('click', function(event) {
|
| 1497 |
+
const dropdown = document.getElementById('episodeFilterDropdown');
|
| 1498 |
+
const toggle = document.getElementById('episodeFilterToggle');
|
| 1499 |
+
|
| 1500 |
+
if (dropdown && toggle && !dropdown.contains(event.target) && !toggle.contains(event.target)) {
|
| 1501 |
+
dropdown.style.display = 'none';
|
| 1502 |
+
document.getElementById('episodeFilterToggleIcon').style.transform = 'rotate(0deg)';
|
| 1503 |
+
}
|
| 1504 |
+
});
|
| 1505 |
+
|
| 1506 |
+
// 선택된 회차 수 업데이트
|
| 1507 |
+
function updateEpisodeFilterButtonText() {
|
| 1508 |
+
const toggle = document.getElementById('episodeFilterToggle');
|
| 1509 |
+
const episodeFilterAll = document.getElementById('episodeFilterAll');
|
| 1510 |
+
const episodeFilterList = document.getElementById('episodeFilterList');
|
| 1511 |
+
|
| 1512 |
+
if (!toggle || !episodeFilterList) return;
|
| 1513 |
+
|
| 1514 |
+
let selectedCount = 0;
|
| 1515 |
+
let buttonText = '회차 필터';
|
| 1516 |
+
|
| 1517 |
+
if (episodeFilterAll && episodeFilterAll.checked) {
|
| 1518 |
+
buttonText = '회차 필터 (전체)';
|
| 1519 |
+
} else {
|
| 1520 |
+
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]:checked');
|
| 1521 |
+
selectedCount = checkboxes.length;
|
| 1522 |
+
if (selectedCount > 0) {
|
| 1523 |
+
buttonText = `회차 필터 (${selectedCount}개 선택)`;
|
| 1524 |
+
}
|
| 1525 |
+
}
|
| 1526 |
+
|
| 1527 |
+
toggle.querySelector('span:first-child').textContent = buttonText;
|
| 1528 |
+
}
|
| 1529 |
+
|
| 1530 |
+
// 전체 회차 체크박스 변경 핸들러
|
| 1531 |
+
function handleEpisodeFilterAllChange() {
|
| 1532 |
+
const episodeFilterAll = document.getElementById('episodeFilterAll');
|
| 1533 |
+
const episodeFilterList = document.getElementById('episodeFilterList');
|
| 1534 |
+
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]');
|
| 1535 |
+
|
| 1536 |
+
// 전체 회차가 체크되면 모든 개별 회차 체크 해제
|
| 1537 |
+
if (episodeFilterAll.checked) {
|
| 1538 |
+
checkboxes.forEach(checkbox => {
|
| 1539 |
+
checkbox.checked = false;
|
| 1540 |
+
});
|
| 1541 |
+
}
|
| 1542 |
+
|
| 1543 |
+
updateEpisodeFilterButtonText();
|
| 1544 |
+
updateGraphVisualization();
|
| 1545 |
+
}
|
| 1546 |
+
|
| 1547 |
+
// 개별 회차 체크박스 변경 ��들러 (전체 회차 자동 해제)
|
| 1548 |
+
function handleIndividualEpisodeChange() {
|
| 1549 |
+
const episodeFilterAll = document.getElementById('episodeFilterAll');
|
| 1550 |
+
const episodeFilterList = document.getElementById('episodeFilterList');
|
| 1551 |
+
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]');
|
| 1552 |
+
const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length;
|
| 1553 |
+
|
| 1554 |
+
// 개별 회차가 하나라도 체크되면 전체 회차 체크 해제
|
| 1555 |
+
if (checkedCount > 0) {
|
| 1556 |
+
episodeFilterAll.checked = false;
|
| 1557 |
+
}
|
| 1558 |
+
|
| 1559 |
+
updateEpisodeFilterButtonText();
|
| 1560 |
+
updateGraphVisualization();
|
| 1561 |
}
|
| 1562 |
|
| 1563 |
function updateGraphVisualization() {
|
| 1564 |
if (!allGraphData) return;
|
| 1565 |
|
| 1566 |
+
// 선택된 회차들 가져오기
|
| 1567 |
+
const episodeFilterAll = document.getElementById('episodeFilterAll');
|
| 1568 |
+
let selectedEpisodes = [];
|
| 1569 |
+
|
| 1570 |
+
if (episodeFilterAll.checked) {
|
| 1571 |
+
// 전체 회차 선택
|
| 1572 |
+
selectedEpisodes = 'all';
|
| 1573 |
+
} else {
|
| 1574 |
+
// 개별 회차 체크박스에서 선택된 것들 가져오기
|
| 1575 |
+
const episodeFilterList = document.getElementById('episodeFilterList');
|
| 1576 |
+
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]:checked');
|
| 1577 |
+
selectedEpisodes = Array.from(checkboxes).map(cb => cb.value);
|
| 1578 |
+
}
|
| 1579 |
|
| 1580 |
// 기존 네트워크 제거
|
| 1581 |
if (graphNetwork) {
|
|
|
|
| 1584 |
}
|
| 1585 |
|
| 1586 |
// 새 그래프 생성
|
| 1587 |
+
createGraphVisualization(allGraphData, selectedEpisodes);
|
| 1588 |
}
|
| 1589 |
|
| 1590 |
function resetGraphView() {
|
| 1591 |
+
if (!graphNetwork) {
|
| 1592 |
+
console.warn('그래프 네트워크가 아직 생성되지 않았습니다.');
|
| 1593 |
+
return;
|
| 1594 |
+
}
|
| 1595 |
+
|
| 1596 |
+
try {
|
| 1597 |
+
// 그래프를 초기 뷰로 리셋 (모든 노드가 보이도록)
|
| 1598 |
+
// vis-network의 fit() 메서드 호출
|
| 1599 |
+
if (typeof graphNetwork.fit === 'function') {
|
| 1600 |
+
// 옵션 객체를 사용한 fit() 호출
|
| 1601 |
+
graphNetwork.fit({
|
| 1602 |
+
animation: {
|
| 1603 |
+
duration: 1000,
|
| 1604 |
+
easingFunction: 'easeInOutQuad'
|
| 1605 |
+
}
|
| 1606 |
+
});
|
| 1607 |
+
} else {
|
| 1608 |
+
// fit() 메서드가 없는 경우 (이론적으로는 발생하지 않아야 함)
|
| 1609 |
+
console.warn('fit() 메서드를 찾을 수 없습니다.');
|
| 1610 |
+
}
|
| 1611 |
+
} catch (error) {
|
| 1612 |
+
console.error('뷰 리셋 오류:', error);
|
| 1613 |
+
// 에러 발생 시 옵션 없이 fit() 호출로 재시도
|
| 1614 |
+
try {
|
| 1615 |
+
if (typeof graphNetwork.fit === 'function') {
|
| 1616 |
+
graphNetwork.fit();
|
| 1617 |
}
|
| 1618 |
+
} catch (e) {
|
| 1619 |
+
console.error('뷰 리셋 실패:', e);
|
| 1620 |
+
}
|
| 1621 |
}
|
| 1622 |
}
|
| 1623 |
|
templates/index.html
CHANGED
|
@@ -663,6 +663,24 @@
|
|
| 663 |
background: var(--bg-tertiary);
|
| 664 |
}
|
| 665 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
/* 채팅 영역 */
|
| 667 |
.chat-container {
|
| 668 |
flex: 1;
|
|
@@ -1259,10 +1277,12 @@
|
|
| 1259 |
<span></span>
|
| 1260 |
</div>
|
| 1261 |
<div class="header-actions">
|
| 1262 |
-
<
|
| 1263 |
-
|
|
|
|
| 1264 |
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8"/>
|
| 1265 |
</svg>
|
|
|
|
| 1266 |
</a>
|
| 1267 |
{% if current_user.is_admin %}
|
| 1268 |
<a href="{{ url_for('main.admin') }}" class="btn-icon" title="관리자 페이지" style="text-decoration: none; color: var(--text-secondary);">
|
|
@@ -1271,7 +1291,6 @@
|
|
| 1271 |
</svg>
|
| 1272 |
</a>
|
| 1273 |
{% endif %}
|
| 1274 |
-
<span style="margin-right: 8px; font-size: 14px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span>
|
| 1275 |
<a href="{{ url_for('main.logout') }}" class="btn-icon" title="로그아웃" style="text-decoration: none; color: var(--text-secondary);">
|
| 1276 |
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1277 |
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
|
|
|
|
| 663 |
background: var(--bg-tertiary);
|
| 664 |
}
|
| 665 |
|
| 666 |
+
.btn-text-icon {
|
| 667 |
+
display: flex;
|
| 668 |
+
align-items: center;
|
| 669 |
+
gap: 6px;
|
| 670 |
+
padding: 6px 12px;
|
| 671 |
+
border-radius: 6px;
|
| 672 |
+
transition: background 0.2s;
|
| 673 |
+
font-size: 14px;
|
| 674 |
+
font-weight: 500;
|
| 675 |
+
color: var(--text-primary);
|
| 676 |
+
text-decoration: none;
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
.btn-text-icon:hover {
|
| 680 |
+
background: var(--bg-tertiary);
|
| 681 |
+
color: var(--accent);
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
/* 채팅 영역 */
|
| 685 |
.chat-container {
|
| 686 |
flex: 1;
|
|
|
|
| 1277 |
<span></span>
|
| 1278 |
</div>
|
| 1279 |
<div class="header-actions">
|
| 1280 |
+
<span style="margin-right: 8px; font-size: 14px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span>
|
| 1281 |
+
<a href="{{ url_for('main.webnovels') }}" class="btn-text-icon" title="원작 정보">
|
| 1282 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1283 |
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8"/>
|
| 1284 |
</svg>
|
| 1285 |
+
<span>원작 정보</span>
|
| 1286 |
</a>
|
| 1287 |
{% if current_user.is_admin %}
|
| 1288 |
<a href="{{ url_for('main.admin') }}" class="btn-icon" title="관리자 페이지" style="text-decoration: none; color: var(--text-secondary);">
|
|
|
|
| 1291 |
</svg>
|
| 1292 |
</a>
|
| 1293 |
{% endif %}
|
|
|
|
| 1294 |
<a href="{{ url_for('main.logout') }}" class="btn-icon" title="로그아웃" style="text-decoration: none; color: var(--text-secondary);">
|
| 1295 |
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1296 |
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
|
templates/webnovels.html
CHANGED
|
@@ -442,6 +442,33 @@
|
|
| 442 |
.content-section {
|
| 443 |
scroll-margin-top: 20px;
|
| 444 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
</style>
|
| 446 |
</head>
|
| 447 |
<body>
|
|
@@ -523,14 +550,28 @@
|
|
| 523 |
|
| 524 |
<!-- GraphRAG 모달 -->
|
| 525 |
<div id="graphRAGModal" class="modal">
|
| 526 |
-
<div class="modal-content" style="max-width:
|
| 527 |
-
<div class="modal-header">
|
| 528 |
<div class="modal-title" id="graphRAGModalTitle">회차별 캐릭터 관계 분석</div>
|
| 529 |
<button class="modal-close" onclick="closeGraphRAGModal()">×</button>
|
| 530 |
</div>
|
| 531 |
-
<div
|
| 532 |
-
|
| 533 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
</div>
|
| 535 |
</div>
|
| 536 |
</div>
|
|
@@ -544,10 +585,23 @@
|
|
| 544 |
<button class="modal-close" onclick="closeGraphRAGVisualizationModal()">×</button>
|
| 545 |
</div>
|
| 546 |
<div style="padding: 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
|
| 547 |
-
<
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
<label style="font-size: 14px; font-weight: 500; margin-left: 16px;">노드 타입:</label>
|
| 552 |
<label style="font-size: 13px; margin-left: 8px;">
|
| 553 |
<input type="checkbox" id="showCharacters" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
|
|
@@ -1357,13 +1411,97 @@
|
|
| 1357 |
}
|
| 1358 |
});
|
| 1359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1360 |
async function viewGraphRAG(fileId, fileName) {
|
| 1361 |
const modal = document.getElementById('graphRAGModal');
|
| 1362 |
const title = document.getElementById('graphRAGModalTitle');
|
| 1363 |
const content = document.getElementById('graphRAGContent');
|
|
|
|
| 1364 |
|
| 1365 |
title.textContent = `회차별 캐릭터 관계 분석 - ${fileName}`;
|
| 1366 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">GraphRAG 데이터를 불러오는 중...</div>';
|
|
|
|
| 1367 |
modal.classList.add('active');
|
| 1368 |
|
| 1369 |
try {
|
|
@@ -1374,6 +1512,27 @@
|
|
| 1374 |
|
| 1375 |
const data = await response.json();
|
| 1376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1377 |
let contentHtml = '';
|
| 1378 |
|
| 1379 |
// 통계 정보
|
|
@@ -1389,14 +1548,12 @@
|
|
| 1389 |
contentHtml += '</div>';
|
| 1390 |
}
|
| 1391 |
|
| 1392 |
-
// 회차별 데이터 표시
|
| 1393 |
-
const episodes = data.episodes || [];
|
| 1394 |
-
|
| 1395 |
if (episodes.length === 0) {
|
| 1396 |
contentHtml += '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">GraphRAG 데이터가 없습니다.</div>';
|
| 1397 |
} else {
|
| 1398 |
episodes.forEach(episode => {
|
| 1399 |
-
|
|
|
|
| 1400 |
contentHtml += `<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 20px; color: var(--accent);">${escapeHtml(episode)}</h3>`;
|
| 1401 |
|
| 1402 |
// 엔티티 (인물)
|
|
@@ -1503,6 +1660,13 @@
|
|
| 1503 |
}
|
| 1504 |
|
| 1505 |
content.innerHTML = contentHtml;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1506 |
} catch (error) {
|
| 1507 |
console.error('GraphRAG 데이터 로드 오류:', error);
|
| 1508 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335;">GraphRAG 데이터를 불러오는 중 오류가 발생했습니다.</div>';
|
|
@@ -1524,7 +1688,7 @@
|
|
| 1524 |
const content = document.getElementById('graphRAGVisualizationContent');
|
| 1525 |
|
| 1526 |
title.textContent = `캐릭터 관계도 시각화 - ${fileName}`;
|
| 1527 |
-
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);"
|
| 1528 |
modal.classList.add('active');
|
| 1529 |
|
| 1530 |
// 기존 네트워크 제거
|
|
@@ -1533,6 +1697,11 @@
|
|
| 1533 |
webnovelGraphNetwork = null;
|
| 1534 |
}
|
| 1535 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1536 |
try {
|
| 1537 |
const response = await fetch(`/api/files/${fileId}/graph`, {
|
| 1538 |
credentials: 'include'
|
|
@@ -1542,20 +1711,43 @@
|
|
| 1542 |
const data = await response.json();
|
| 1543 |
webnovelAllGraphData = data;
|
| 1544 |
|
| 1545 |
-
// 회차 필터
|
| 1546 |
-
const
|
| 1547 |
-
|
|
|
|
|
|
|
|
|
|
| 1548 |
if (data.episodes && data.episodes.length > 0) {
|
| 1549 |
-
data.episodes
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1554 |
});
|
| 1555 |
}
|
| 1556 |
|
| 1557 |
-
//
|
| 1558 |
-
|
|
|
|
|
|
|
|
|
|
| 1559 |
} catch (error) {
|
| 1560 |
console.error('GraphRAG 그래프 로드 오류:', error);
|
| 1561 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">그래프를 불러오는 중 오류가 발생했습니다.</div>';
|
|
@@ -1564,6 +1756,23 @@
|
|
| 1564 |
|
| 1565 |
function createWebnovelGraphVisualization(data, episodeFilter = 'all') {
|
| 1566 |
const content = document.getElementById('graphRAGVisualizationContent');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1567 |
content.innerHTML = ''; // 기존 내용 제거
|
| 1568 |
|
| 1569 |
// 노드와 엣지 데이터 생성
|
|
@@ -1573,14 +1782,10 @@
|
|
| 1573 |
const nodeMap = new Map(); // 노드 ID 매핑
|
| 1574 |
let nodeIdCounter = 1;
|
| 1575 |
|
| 1576 |
-
|
| 1577 |
-
const showLocations = document.getElementById('showLocations').checked;
|
| 1578 |
-
const showEvents = document.getElementById('showEvents').checked;
|
| 1579 |
-
|
| 1580 |
-
// 필터링할 회차 목록
|
| 1581 |
const episodes = episodeFilter === 'all'
|
| 1582 |
? (data.episodes || [])
|
| 1583 |
-
: [episodeFilter];
|
| 1584 |
|
| 1585 |
// 엔티티 추가 (인물, 장소)
|
| 1586 |
episodes.forEach(episode => {
|
|
@@ -1830,12 +2035,140 @@
|
|
| 1830 |
}
|
| 1831 |
}
|
| 1832 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1833 |
}
|
| 1834 |
|
| 1835 |
function updateGraphVisualization() {
|
| 1836 |
if (!webnovelAllGraphData) return;
|
| 1837 |
|
| 1838 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1839 |
|
| 1840 |
// 기존 네트워크 제거
|
| 1841 |
if (webnovelGraphNetwork) {
|
|
@@ -1844,17 +2177,40 @@
|
|
| 1844 |
}
|
| 1845 |
|
| 1846 |
// 새 그래프 생성
|
| 1847 |
-
createWebnovelGraphVisualization(webnovelAllGraphData,
|
| 1848 |
}
|
| 1849 |
|
| 1850 |
function resetGraphView() {
|
| 1851 |
-
if (webnovelGraphNetwork) {
|
| 1852 |
-
|
| 1853 |
-
|
| 1854 |
-
|
| 1855 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1856 |
}
|
| 1857 |
-
})
|
|
|
|
|
|
|
| 1858 |
}
|
| 1859 |
}
|
| 1860 |
|
|
|
|
| 442 |
.content-section {
|
| 443 |
scroll-margin-top: 20px;
|
| 444 |
}
|
| 445 |
+
|
| 446 |
+
/* GraphRAG 사이드바 스타일 */
|
| 447 |
+
.episode-sidebar-item {
|
| 448 |
+
padding: 10px 12px;
|
| 449 |
+
border-radius: 6px;
|
| 450 |
+
cursor: pointer;
|
| 451 |
+
transition: all 0.2s;
|
| 452 |
+
font-size: 14px;
|
| 453 |
+
color: var(--text-primary);
|
| 454 |
+
margin-bottom: 4px;
|
| 455 |
+
border: 1px solid transparent;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
.episode-sidebar-item:hover {
|
| 459 |
+
background: var(--ai-bg);
|
| 460 |
+
border-color: var(--accent);
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.episode-sidebar-item.active {
|
| 464 |
+
background: var(--accent);
|
| 465 |
+
color: white;
|
| 466 |
+
font-weight: 500;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
.episode-sidebar-item.active:hover {
|
| 470 |
+
background: var(--accent-hover);
|
| 471 |
+
}
|
| 472 |
</style>
|
| 473 |
</head>
|
| 474 |
<body>
|
|
|
|
| 550 |
|
| 551 |
<!-- GraphRAG 모달 -->
|
| 552 |
<div id="graphRAGModal" class="modal">
|
| 553 |
+
<div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh; display: flex; flex-direction: column; padding: 0;">
|
| 554 |
+
<div class="modal-header" style="flex-shrink: 0; padding: 24px 24px 16px 24px; margin-bottom: 0;">
|
| 555 |
<div class="modal-title" id="graphRAGModalTitle">회차별 캐릭터 관계 분석</div>
|
| 556 |
<button class="modal-close" onclick="closeGraphRAGModal()">×</button>
|
| 557 |
</div>
|
| 558 |
+
<div style="display: flex; flex: 1; overflow: hidden;">
|
| 559 |
+
<!-- 좌측 사이드바 (회차 목록) -->
|
| 560 |
+
<div id="graphRAGSidebar" style="width: 250px; background: var(--bg-secondary); border-right: 1px solid var(--border); overflow-y: auto; flex-shrink: 0; padding: 16px;">
|
| 561 |
+
<div style="font-size: 14px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border);">
|
| 562 |
+
회차 목록
|
| 563 |
+
</div>
|
| 564 |
+
<div id="graphRAGEpisodeList" style="display: flex; flex-direction: column; gap: 4px;">
|
| 565 |
+
<div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;">
|
| 566 |
+
회차 목록을 불러오는 중...
|
| 567 |
+
</div>
|
| 568 |
+
</div>
|
| 569 |
+
</div>
|
| 570 |
+
<!-- 우측 콘텐츠 영역 -->
|
| 571 |
+
<div id="graphRAGContent" style="flex: 1; overflow-y: auto; padding: 24px;">
|
| 572 |
+
<div style="text-align: center; padding: 24px; color: var(--text-secondary);">
|
| 573 |
+
GraphRAG 데이터를 불러오는 중...
|
| 574 |
+
</div>
|
| 575 |
</div>
|
| 576 |
</div>
|
| 577 |
</div>
|
|
|
|
| 585 |
<button class="modal-close" onclick="closeGraphRAGVisualizationModal()">×</button>
|
| 586 |
</div>
|
| 587 |
<div style="padding: 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
|
| 588 |
+
<div style="position: relative;">
|
| 589 |
+
<button id="episodeFilterToggle" onclick="toggleWebnovelEpisodeFilter()" style="padding: 8px 16px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; color: var(--text-primary); font-weight: 500;">
|
| 590 |
+
<span>회차 필터</span>
|
| 591 |
+
<span id="episodeFilterToggleIcon" style="font-size: 12px; transition: transform 0.2s;">▼</span>
|
| 592 |
+
</button>
|
| 593 |
+
<div id="episodeFilterDropdown" style="display: none; position: absolute; top: 100%; left: 0; margin-top: 4px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 1000; min-width: 250px; max-width: 400px; max-height: 400px; overflow-y: auto;">
|
| 594 |
+
<div style="padding: 12px; border-bottom: 1px solid var(--border);">
|
| 595 |
+
<label style="font-size: 13px; cursor: pointer; padding: 6px 8px; border-radius: 4px; display: flex; align-items: center; transition: background 0.2s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">
|
| 596 |
+
<input type="checkbox" id="episodeFilterAll" onchange="handleWebnovelEpisodeFilterAllChange()" style="margin-right: 8px;">
|
| 597 |
+
<span style="font-weight: 500;">전체 회차</span>
|
| 598 |
+
</label>
|
| 599 |
+
</div>
|
| 600 |
+
<div id="episodeFilterList" style="padding: 8px; display: flex; flex-direction: column; gap: 2px;">
|
| 601 |
+
<!-- 회차 체크박스 목록이 여기에 동적으로 추가됩니다 -->
|
| 602 |
+
</div>
|
| 603 |
+
</div>
|
| 604 |
+
</div>
|
| 605 |
<label style="font-size: 14px; font-weight: 500; margin-left: 16px;">노드 타입:</label>
|
| 606 |
<label style="font-size: 13px; margin-left: 8px;">
|
| 607 |
<input type="checkbox" id="showCharacters" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
|
|
|
|
| 1411 |
}
|
| 1412 |
});
|
| 1413 |
|
| 1414 |
+
// 회차를 숫자 순서로 정렬하는 함수 (1화, 2화... 99화, 100화, 101화)
|
| 1415 |
+
function sortEpisodesByNumber(episodes) {
|
| 1416 |
+
return episodes.slice().sort((a, b) => {
|
| 1417 |
+
// 숫자 추출 (예: "1화" -> 1, "100화" -> 100)
|
| 1418 |
+
const numA = parseInt(a.match(/\d+/)?.[0] || '0');
|
| 1419 |
+
const numB = parseInt(b.match(/\d+/)?.[0] || '0');
|
| 1420 |
+
return numA - numB;
|
| 1421 |
+
});
|
| 1422 |
+
}
|
| 1423 |
+
|
| 1424 |
+
// 특정 회차로 스크롤
|
| 1425 |
+
function scrollToEpisode(episodeId) {
|
| 1426 |
+
const element = document.getElementById(episodeId);
|
| 1427 |
+
if (element) {
|
| 1428 |
+
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 1429 |
+
|
| 1430 |
+
// 사이드바에서 활성 상태 업데이트
|
| 1431 |
+
const sidebarItems = document.querySelectorAll('.episode-sidebar-item');
|
| 1432 |
+
sidebarItems.forEach(item => item.classList.remove('active'));
|
| 1433 |
+
|
| 1434 |
+
// 클릭한 아이템 활성화
|
| 1435 |
+
const clickedItem = Array.from(sidebarItems).find(item => {
|
| 1436 |
+
const itemEpisodeId = `episode-${item.textContent.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
| 1437 |
+
return itemEpisodeId === episodeId;
|
| 1438 |
+
});
|
| 1439 |
+
if (clickedItem) {
|
| 1440 |
+
clickedItem.classList.add('active');
|
| 1441 |
+
}
|
| 1442 |
+
}
|
| 1443 |
+
}
|
| 1444 |
+
|
| 1445 |
+
// 현재 보이는 회차를 하이라이트
|
| 1446 |
+
function updateActiveEpisode(episodes) {
|
| 1447 |
+
const contentElement = document.getElementById('graphRAGContent');
|
| 1448 |
+
const sidebarItems = document.querySelectorAll('.episode-sidebar-item');
|
| 1449 |
+
|
| 1450 |
+
if (!contentElement || sidebarItems.length === 0) return;
|
| 1451 |
+
|
| 1452 |
+
const scrollTop = contentElement.scrollTop;
|
| 1453 |
+
const viewportHeight = contentElement.clientHeight;
|
| 1454 |
+
const scrollBottom = scrollTop + viewportHeight;
|
| 1455 |
+
|
| 1456 |
+
// 통계 정보 섹션 높이 고려 (대략 150px)
|
| 1457 |
+
const statsOffset = 150;
|
| 1458 |
+
|
| 1459 |
+
let activeEpisode = null;
|
| 1460 |
+
let activeElement = null;
|
| 1461 |
+
|
| 1462 |
+
episodes.forEach(episode => {
|
| 1463 |
+
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
| 1464 |
+
const element = document.getElementById(episodeId);
|
| 1465 |
+
|
| 1466 |
+
if (element) {
|
| 1467 |
+
const elementTop = element.offsetTop - statsOffset;
|
| 1468 |
+
const elementBottom = elementTop + element.offsetHeight;
|
| 1469 |
+
|
| 1470 |
+
// 요소가 뷰포트 상단 근처에 있으면 활성화
|
| 1471 |
+
if (elementTop <= scrollTop + 100 && elementBottom > scrollTop) {
|
| 1472 |
+
activeEpisode = episode;
|
| 1473 |
+
activeElement = element;
|
| 1474 |
+
}
|
| 1475 |
+
}
|
| 1476 |
+
});
|
| 1477 |
+
|
| 1478 |
+
// 첫 번째 회차가 보이지 않으면 첫 번째 회차를 활성화
|
| 1479 |
+
if (!activeEpisode && episodes.length > 0) {
|
| 1480 |
+
const firstEpisode = episodes[0];
|
| 1481 |
+
const firstElement = document.getElementById(`episode-${firstEpisode.replace(/[^a-zA-Z0-9]/g, '-')}`);
|
| 1482 |
+
if (firstElement && firstElement.offsetTop - 150 > scrollTop + viewportHeight) {
|
| 1483 |
+
activeEpisode = firstEpisode;
|
| 1484 |
+
}
|
| 1485 |
+
}
|
| 1486 |
+
|
| 1487 |
+
// 사이드바 아이템 활성 상태 업데이트
|
| 1488 |
+
sidebarItems.forEach(item => {
|
| 1489 |
+
item.classList.remove('active');
|
| 1490 |
+
if (activeEpisode && item.textContent === activeEpisode) {
|
| 1491 |
+
item.classList.add('active');
|
| 1492 |
+
}
|
| 1493 |
+
});
|
| 1494 |
+
}
|
| 1495 |
+
|
| 1496 |
async function viewGraphRAG(fileId, fileName) {
|
| 1497 |
const modal = document.getElementById('graphRAGModal');
|
| 1498 |
const title = document.getElementById('graphRAGModalTitle');
|
| 1499 |
const content = document.getElementById('graphRAGContent');
|
| 1500 |
+
const sidebar = document.getElementById('graphRAGEpisodeList');
|
| 1501 |
|
| 1502 |
title.textContent = `회차별 캐릭터 관계 분석 - ${fileName}`;
|
| 1503 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">GraphRAG 데이터를 불러오는 중...</div>';
|
| 1504 |
+
sidebar.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;">회차 목록을 불러오는 중...</div>';
|
| 1505 |
modal.classList.add('active');
|
| 1506 |
|
| 1507 |
try {
|
|
|
|
| 1512 |
|
| 1513 |
const data = await response.json();
|
| 1514 |
|
| 1515 |
+
// 회차별 데이터 표시 (숫자 순서로 정렬)
|
| 1516 |
+
const episodes = sortEpisodesByNumber(data.episodes || []);
|
| 1517 |
+
|
| 1518 |
+
// 사이드바에 회차 목록 표시
|
| 1519 |
+
sidebar.innerHTML = '';
|
| 1520 |
+
if (episodes.length === 0) {
|
| 1521 |
+
sidebar.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;">회차가 없습니다.</div>';
|
| 1522 |
+
} else {
|
| 1523 |
+
episodes.forEach((episode, index) => {
|
| 1524 |
+
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
| 1525 |
+
const item = document.createElement('div');
|
| 1526 |
+
item.className = 'episode-sidebar-item';
|
| 1527 |
+
item.textContent = episode;
|
| 1528 |
+
item.onclick = () => scrollToEpisode(episodeId);
|
| 1529 |
+
if (index === 0) {
|
| 1530 |
+
item.classList.add('active');
|
| 1531 |
+
}
|
| 1532 |
+
sidebar.appendChild(item);
|
| 1533 |
+
});
|
| 1534 |
+
}
|
| 1535 |
+
|
| 1536 |
let contentHtml = '';
|
| 1537 |
|
| 1538 |
// 통계 정보
|
|
|
|
| 1548 |
contentHtml += '</div>';
|
| 1549 |
}
|
| 1550 |
|
|
|
|
|
|
|
|
|
|
| 1551 |
if (episodes.length === 0) {
|
| 1552 |
contentHtml += '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">GraphRAG 데이터가 없습니다.</div>';
|
| 1553 |
} else {
|
| 1554 |
episodes.forEach(episode => {
|
| 1555 |
+
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
| 1556 |
+
contentHtml += `<div id="${episodeId}" style="margin-bottom: 32px; padding: 20px; background: var(--bg-secondary); border-radius: 8px; border-left: 4px solid var(--accent); scroll-margin-top: 20px;">`;
|
| 1557 |
contentHtml += `<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 20px; color: var(--accent);">${escapeHtml(episode)}</h3>`;
|
| 1558 |
|
| 1559 |
// 엔티티 (인물)
|
|
|
|
| 1660 |
}
|
| 1661 |
|
| 1662 |
content.innerHTML = contentHtml;
|
| 1663 |
+
|
| 1664 |
+
// 스크롤 이벤트 리스너 추가 (현재 보이는 회차 하이라이트)
|
| 1665 |
+
const contentElement = document.getElementById('graphRAGContent');
|
| 1666 |
+
contentElement.addEventListener('scroll', () => updateActiveEpisode(episodes));
|
| 1667 |
+
|
| 1668 |
+
// 초기 활성 회차 설정
|
| 1669 |
+
updateActiveEpisode(episodes);
|
| 1670 |
} catch (error) {
|
| 1671 |
console.error('GraphRAG 데이터 로드 오류:', error);
|
| 1672 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335;">GraphRAG 데이터를 불러오는 중 오류가 발생했습니다.</div>';
|
|
|
|
| 1688 |
const content = document.getElementById('graphRAGVisualizationContent');
|
| 1689 |
|
| 1690 |
title.textContent = `캐릭터 관계도 시각화 - ${fileName}`;
|
| 1691 |
+
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">회차와 노드 타입을 선택하여 그래프를 확인하세요.</div>';
|
| 1692 |
modal.classList.add('active');
|
| 1693 |
|
| 1694 |
// 기존 네트워크 제거
|
|
|
|
| 1697 |
webnovelGraphNetwork = null;
|
| 1698 |
}
|
| 1699 |
|
| 1700 |
+
// 체크박스 초기화
|
| 1701 |
+
document.getElementById('showCharacters').checked = false;
|
| 1702 |
+
document.getElementById('showLocations').checked = false;
|
| 1703 |
+
document.getElementById('showEvents').checked = false;
|
| 1704 |
+
|
| 1705 |
try {
|
| 1706 |
const response = await fetch(`/api/files/${fileId}/graph`, {
|
| 1707 |
credentials: 'include'
|
|
|
|
| 1711 |
const data = await response.json();
|
| 1712 |
webnovelAllGraphData = data;
|
| 1713 |
|
| 1714 |
+
// 회차 필터 체크박스 생성 (숫자 순서로 정렬)
|
| 1715 |
+
const episodeFilterList = document.getElementById('episodeFilterList');
|
| 1716 |
+
episodeFilterList.innerHTML = '';
|
| 1717 |
+
const episodeFilterAll = document.getElementById('episodeFilterAll');
|
| 1718 |
+
episodeFilterAll.checked = false;
|
| 1719 |
+
|
| 1720 |
if (data.episodes && data.episodes.length > 0) {
|
| 1721 |
+
const sortedEpisodes = sortEpisodesByNumber(data.episodes);
|
| 1722 |
+
sortedEpisodes.forEach(episode => {
|
| 1723 |
+
const label = document.createElement('label');
|
| 1724 |
+
label.style.cssText = 'font-size: 13px; cursor: pointer; padding: 6px 12px; border-radius: 4px; display: flex; align-items: center; transition: background 0.2s;';
|
| 1725 |
+
label.onmouseover = function() { this.style.background = 'var(--bg-secondary)'; };
|
| 1726 |
+
label.onmouseout = function() { this.style.background = 'transparent'; };
|
| 1727 |
+
|
| 1728 |
+
const checkbox = document.createElement('input');
|
| 1729 |
+
checkbox.type = 'checkbox';
|
| 1730 |
+
checkbox.value = episode;
|
| 1731 |
+
checkbox.id = `episodeFilter_${episode.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
| 1732 |
+
checkbox.onchange = handleWebnovelIndividualEpisodeChange;
|
| 1733 |
+
checkbox.style.marginRight = '8px';
|
| 1734 |
+
checkbox.style.cursor = 'pointer';
|
| 1735 |
+
|
| 1736 |
+
const span = document.createElement('span');
|
| 1737 |
+
span.textContent = episode;
|
| 1738 |
+
span.style.flex = '1';
|
| 1739 |
+
|
| 1740 |
+
label.appendChild(checkbox);
|
| 1741 |
+
label.appendChild(span);
|
| 1742 |
+
episodeFilterList.appendChild(label);
|
| 1743 |
});
|
| 1744 |
}
|
| 1745 |
|
| 1746 |
+
// 버튼 텍스트 초기화
|
| 1747 |
+
updateWebnovelEpisodeFilterButtonText();
|
| 1748 |
+
|
| 1749 |
+
// 초기에는 그래프를 생성하지 않음 (빈 화면)
|
| 1750 |
+
// 사용자가 필터나 체크박스를 선택하면 그래프가 생성됨
|
| 1751 |
} catch (error) {
|
| 1752 |
console.error('GraphRAG 그래프 로드 오류:', error);
|
| 1753 |
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">그래프를 불러오는 중 오류가 발생했습니다.</div>';
|
|
|
|
| 1756 |
|
| 1757 |
function createWebnovelGraphVisualization(data, episodeFilter = 'all') {
|
| 1758 |
const content = document.getElementById('graphRAGVisualizationContent');
|
| 1759 |
+
|
| 1760 |
+
// 필터가 선택되지 않았거나 빈 배열인 경우 빈 화면 표시
|
| 1761 |
+
if (!episodeFilter || (Array.isArray(episodeFilter) && episodeFilter.length === 0)) {
|
| 1762 |
+
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">회차를 선택하여 그래프를 확인하세요.</div>';
|
| 1763 |
+
return;
|
| 1764 |
+
}
|
| 1765 |
+
|
| 1766 |
+
const showCharacters = document.getElementById('showCharacters').checked;
|
| 1767 |
+
const showLocations = document.getElementById('showLocations').checked;
|
| 1768 |
+
const showEvents = document.getElementById('showEvents').checked;
|
| 1769 |
+
|
| 1770 |
+
// 체크박스가 모두 해제된 경우 빈 화면 표시
|
| 1771 |
+
if (!showCharacters && !showLocations && !showEvents) {
|
| 1772 |
+
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">노드 타입(인물, 장소, 사건)을 하나 이상 선택하여 그래프를 확인하세요.</div>';
|
| 1773 |
+
return;
|
| 1774 |
+
}
|
| 1775 |
+
|
| 1776 |
content.innerHTML = ''; // 기존 내용 제거
|
| 1777 |
|
| 1778 |
// 노드와 엣지 데이터 생성
|
|
|
|
| 1782 |
const nodeMap = new Map(); // 노드 ID 매핑
|
| 1783 |
let nodeIdCounter = 1;
|
| 1784 |
|
| 1785 |
+
// 필터링할 회차 목록 (다중 선택 지원)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1786 |
const episodes = episodeFilter === 'all'
|
| 1787 |
? (data.episodes || [])
|
| 1788 |
+
: (Array.isArray(episodeFilter) ? episodeFilter : [episodeFilter]);
|
| 1789 |
|
| 1790 |
// 엔티티 추가 (인물, 장소)
|
| 1791 |
episodes.forEach(episode => {
|
|
|
|
| 2035 |
}
|
| 2036 |
}
|
| 2037 |
});
|
| 2038 |
+
|
| 2039 |
+
// stabilization 완료 후 자동으로 fit() 호출
|
| 2040 |
+
webnovelGraphNetwork.on('stabilizationEnd', function() {
|
| 2041 |
+
try {
|
| 2042 |
+
webnovelGraphNetwork.fit({
|
| 2043 |
+
animation: {
|
| 2044 |
+
duration: 500,
|
| 2045 |
+
easingFunction: 'easeInOutQuad'
|
| 2046 |
+
}
|
| 2047 |
+
});
|
| 2048 |
+
} catch (error) {
|
| 2049 |
+
console.error('자동 fit() 오류:', error);
|
| 2050 |
+
}
|
| 2051 |
+
});
|
| 2052 |
+
|
| 2053 |
+
// stabilization이 비활성화된 경우를 대비해 짧은 지연 후에도 fit() 호출
|
| 2054 |
+
setTimeout(() => {
|
| 2055 |
+
if (webnovelGraphNetwork && nodes.length() > 0) {
|
| 2056 |
+
try {
|
| 2057 |
+
webnovelGraphNetwork.fit({
|
| 2058 |
+
animation: {
|
| 2059 |
+
duration: 500,
|
| 2060 |
+
easingFunction: 'easeInOutQuad'
|
| 2061 |
+
}
|
| 2062 |
+
});
|
| 2063 |
+
} catch (error) {
|
| 2064 |
+
console.error('지연 fit() 오류:', error);
|
| 2065 |
+
}
|
| 2066 |
+
}
|
| 2067 |
+
}, 500);
|
| 2068 |
+
}
|
| 2069 |
+
|
| 2070 |
+
// 회차 필터 토글 함수
|
| 2071 |
+
function toggleWebnovelEpisodeFilter() {
|
| 2072 |
+
const dropdown = document.getElementById('episodeFilterDropdown');
|
| 2073 |
+
const icon = document.getElementById('episodeFilterToggleIcon');
|
| 2074 |
+
const isVisible = dropdown.style.display !== 'none';
|
| 2075 |
+
|
| 2076 |
+
if (isVisible) {
|
| 2077 |
+
dropdown.style.display = 'none';
|
| 2078 |
+
icon.style.transform = 'rotate(0deg)';
|
| 2079 |
+
} else {
|
| 2080 |
+
dropdown.style.display = 'block';
|
| 2081 |
+
icon.style.transform = 'rotate(180deg)';
|
| 2082 |
+
}
|
| 2083 |
+
}
|
| 2084 |
+
|
| 2085 |
+
// 외부 클릭 시 회차 필터 드롭다운 닫기
|
| 2086 |
+
document.addEventListener('click', function(event) {
|
| 2087 |
+
const dropdown = document.getElementById('episodeFilterDropdown');
|
| 2088 |
+
const toggle = document.getElementById('episodeFilterToggle');
|
| 2089 |
+
|
| 2090 |
+
if (dropdown && toggle && !dropdown.contains(event.target) && !toggle.contains(event.target)) {
|
| 2091 |
+
dropdown.style.display = 'none';
|
| 2092 |
+
const icon = document.getElementById('episodeFilterToggleIcon');
|
| 2093 |
+
if (icon) {
|
| 2094 |
+
icon.style.transform = 'rotate(0deg)';
|
| 2095 |
+
}
|
| 2096 |
+
}
|
| 2097 |
+
});
|
| 2098 |
+
|
| 2099 |
+
// 선택된 회차 수 업데이트
|
| 2100 |
+
function updateWebnovelEpisodeFilterButtonText() {
|
| 2101 |
+
const toggle = document.getElementById('episodeFilterToggle');
|
| 2102 |
+
const episodeFilterAll = document.getElementById('episodeFilterAll');
|
| 2103 |
+
const episodeFilterList = document.getElementById('episodeFilterList');
|
| 2104 |
+
|
| 2105 |
+
if (!toggle || !episodeFilterList) return;
|
| 2106 |
+
|
| 2107 |
+
let selectedCount = 0;
|
| 2108 |
+
let buttonText = '회차 필터';
|
| 2109 |
+
|
| 2110 |
+
if (episodeFilterAll && episodeFilterAll.checked) {
|
| 2111 |
+
buttonText = '회차 필터 (전체)';
|
| 2112 |
+
} else {
|
| 2113 |
+
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]:checked');
|
| 2114 |
+
selectedCount = checkboxes.length;
|
| 2115 |
+
if (selectedCount > 0) {
|
| 2116 |
+
buttonText = `회차 필터 (${selectedCount}개 선택)`;
|
| 2117 |
+
}
|
| 2118 |
+
}
|
| 2119 |
+
|
| 2120 |
+
toggle.querySelector('span:first-child').textContent = buttonText;
|
| 2121 |
+
}
|
| 2122 |
+
|
| 2123 |
+
// 전체 회차 체크박스 변경 핸들러
|
| 2124 |
+
function handleWebnovelEpisodeFilterAllChange() {
|
| 2125 |
+
const episodeFilterAll = document.getElementById('episodeFilterAll');
|
| 2126 |
+
const episodeFilterList = document.getElementById('episodeFilterList');
|
| 2127 |
+
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]');
|
| 2128 |
+
|
| 2129 |
+
// 전체 회차가 체크되면 모든 개별 회차 체크 해제
|
| 2130 |
+
if (episodeFilterAll.checked) {
|
| 2131 |
+
checkboxes.forEach(checkbox => {
|
| 2132 |
+
checkbox.checked = false;
|
| 2133 |
+
});
|
| 2134 |
+
}
|
| 2135 |
+
|
| 2136 |
+
updateWebnovelEpisodeFilterButtonText();
|
| 2137 |
+
updateGraphVisualization();
|
| 2138 |
+
}
|
| 2139 |
+
|
| 2140 |
+
// 개별 회차 체크박스 변경 핸들러 (전체 회차 자동 해제)
|
| 2141 |
+
function handleWebnovelIndividualEpisodeChange() {
|
| 2142 |
+
const episodeFilterAll = document.getElementById('episodeFilterAll');
|
| 2143 |
+
const episodeFilterList = document.getElementById('episodeFilterList');
|
| 2144 |
+
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]');
|
| 2145 |
+
const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length;
|
| 2146 |
+
|
| 2147 |
+
// 개별 회차가 하나라도 체크되면 전체 회차 체크 해제
|
| 2148 |
+
if (checkedCount > 0) {
|
| 2149 |
+
episodeFilterAll.checked = false;
|
| 2150 |
+
}
|
| 2151 |
+
|
| 2152 |
+
updateWebnovelEpisodeFilterButtonText();
|
| 2153 |
+
updateGraphVisualization();
|
| 2154 |
}
|
| 2155 |
|
| 2156 |
function updateGraphVisualization() {
|
| 2157 |
if (!webnovelAllGraphData) return;
|
| 2158 |
|
| 2159 |
+
// 선택된 회차들 가져오기
|
| 2160 |
+
const episodeFilterAll = document.getElementById('episodeFilterAll');
|
| 2161 |
+
let selectedEpisodes = [];
|
| 2162 |
+
|
| 2163 |
+
if (episodeFilterAll && episodeFilterAll.checked) {
|
| 2164 |
+
// 전체 회차 선택
|
| 2165 |
+
selectedEpisodes = 'all';
|
| 2166 |
+
} else {
|
| 2167 |
+
// 개별 회차 체크박스에서 선택된 것들 가져오기
|
| 2168 |
+
const episodeFilterList = document.getElementById('episodeFilterList');
|
| 2169 |
+
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]:checked');
|
| 2170 |
+
selectedEpisodes = Array.from(checkboxes).map(cb => cb.value);
|
| 2171 |
+
}
|
| 2172 |
|
| 2173 |
// 기존 네트워크 제거
|
| 2174 |
if (webnovelGraphNetwork) {
|
|
|
|
| 2177 |
}
|
| 2178 |
|
| 2179 |
// 새 그래프 생성
|
| 2180 |
+
createWebnovelGraphVisualization(webnovelAllGraphData, selectedEpisodes);
|
| 2181 |
}
|
| 2182 |
|
| 2183 |
function resetGraphView() {
|
| 2184 |
+
if (!webnovelGraphNetwork) {
|
| 2185 |
+
console.warn('그래프 네트워크가 아직 생성되지 않았습니다.');
|
| 2186 |
+
return;
|
| 2187 |
+
}
|
| 2188 |
+
|
| 2189 |
+
try {
|
| 2190 |
+
// 그래프를 초기 뷰로 리셋 (모든 노드가 보이도록)
|
| 2191 |
+
// vis-network의 fit() 메서드 호출
|
| 2192 |
+
if (typeof webnovelGraphNetwork.fit === 'function') {
|
| 2193 |
+
// 옵션 객체를 사용한 fit() 호출
|
| 2194 |
+
webnovelGraphNetwork.fit({
|
| 2195 |
+
animation: {
|
| 2196 |
+
duration: 1000,
|
| 2197 |
+
easingFunction: 'easeInOutQuad'
|
| 2198 |
+
}
|
| 2199 |
+
});
|
| 2200 |
+
} else {
|
| 2201 |
+
// fit() 메서드가 없는 경우 (이론적으로는 발생하지 않아야 함)
|
| 2202 |
+
console.warn('fit() 메서드를 찾을 수 없습니다.');
|
| 2203 |
+
}
|
| 2204 |
+
} catch (error) {
|
| 2205 |
+
console.error('뷰 리셋 오류:', error);
|
| 2206 |
+
// 에러 발생 시 옵션 없이 fit() 호출로 재시도
|
| 2207 |
+
try {
|
| 2208 |
+
if (typeof webnovelGraphNetwork.fit === 'function') {
|
| 2209 |
+
webnovelGraphNetwork.fit();
|
| 2210 |
}
|
| 2211 |
+
} catch (e) {
|
| 2212 |
+
console.error('뷰 리셋 실패:', e);
|
| 2213 |
+
}
|
| 2214 |
}
|
| 2215 |
}
|
| 2216 |
|