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 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: 1400px;">
419
- <div class="modal-header">
420
  <div class="modal-title" id="graphRAGModalTitle">GraphRAG 데이터</div>
421
  <button class="modal-close" onclick="closeGraphRAGModal()">&times;</button>
422
  </div>
423
- <div id="graphRAGContent" style="max-height: 80vh; overflow-y: auto;">
424
- <div style="text-align: center; padding: 24px; color: #5f6368;">
425
- GraphRAG 데이터를 불러오는 중...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  </div>
427
  </div>
428
  </div>
@@ -436,10 +477,23 @@
436
  <button class="modal-close" onclick="closeGraphRAGVisualizationModal()">&times;</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
- <label style="font-size: 14px; font-weight: 500;">회차 필터:</label>
440
- <select id="episodeFilter" onchange="updateGraphVisualization()" style="padding: 6px 12px; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px; min-width: 200px;">
441
- <option value="all">전체 회차</option>
442
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- contentHtml += `<div style="margin-bottom: 32px; padding: 20px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #1a73e8;">`;
 
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%);">그래프를 불러오는 중...</div>';
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 episodeFilter = document.getElementById('episodeFilter');
957
- episodeFilter.innerHTML = '<option value="all">전체 회차</option>';
 
 
 
958
  if (data.episodes && data.episodes.length > 0) {
959
- data.episodes.forEach(episode => {
960
- const option = document.createElement('option');
961
- option.value = episode;
962
- option.textContent = episode;
963
- episodeFilter.appendChild(option);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
964
  });
965
  }
966
 
967
- // 그래프 생성
968
- createGraphVisualization(data);
 
 
 
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
- const showCharacters = document.getElementById('showCharacters').checked;
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
- const episodeFilter = document.getElementById('episodeFilter').value;
 
 
 
 
 
 
 
 
 
 
 
 
1249
 
1250
  // 기존 네트워크 제거
1251
  if (graphNetwork) {
@@ -1254,17 +1584,40 @@
1254
  }
1255
 
1256
  // 새 그래프 생성
1257
- createGraphVisualization(allGraphData, episodeFilter);
1258
  }
1259
 
1260
  function resetGraphView() {
1261
- if (graphNetwork) {
1262
- graphNetwork.fit({
1263
- animation: {
1264
- duration: 1000,
1265
- easingFunction: 'easeInOutQuad'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()">&times;</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()">&times;</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
- <a href="{{ url_for('main.webnovels') }}" class="btn-icon" title="원작 정보" style="text-decoration: none; color: var(--text-secondary);">
1263
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 
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: 1400px;">
527
- <div class="modal-header">
528
  <div class="modal-title" id="graphRAGModalTitle">회차별 캐릭터 관계 분석</div>
529
  <button class="modal-close" onclick="closeGraphRAGModal()">&times;</button>
530
  </div>
531
- <div id="graphRAGContent" style="max-height: 80vh; overflow-y: auto; padding: 24px;">
532
- <div style="text-align: center; padding: 24px; color: var(--text-secondary);">
533
- GraphRAG 데이터를 불러오는 중...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
  </div>
535
  </div>
536
  </div>
@@ -544,10 +585,23 @@
544
  <button class="modal-close" onclick="closeGraphRAGVisualizationModal()">&times;</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
- <label style="font-size: 14px; font-weight: 500;">회차 필터:</label>
548
- <select id="episodeFilter" onchange="updateGraphVisualization()" style="padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; min-width: 200px; background: var(--bg-primary); color: var(--text-primary);">
549
- <option value="all">전체 회차</option>
550
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- contentHtml += `<div style="margin-bottom: 32px; padding: 20px; background: var(--bg-secondary); border-radius: 8px; border-left: 4px solid var(--accent);">`;
 
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%);">그래프를 불러오는 중...</div>';
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 episodeFilter = document.getElementById('episodeFilter');
1547
- episodeFilter.innerHTML = '<option value="all">전체 회차</option>';
 
 
 
1548
  if (data.episodes && data.episodes.length > 0) {
1549
- data.episodes.forEach(episode => {
1550
- const option = document.createElement('option');
1551
- option.value = episode;
1552
- option.textContent = episode;
1553
- episodeFilter.appendChild(option);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1554
  });
1555
  }
1556
 
1557
- // 그래프 생성
1558
- createWebnovelGraphVisualization(data);
 
 
 
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
- const showCharacters = document.getElementById('showCharacters').checked;
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
- const episodeFilter = document.getElementById('episodeFilter').value;
 
 
 
 
 
 
 
 
 
 
 
 
1839
 
1840
  // 기존 네트워크 제거
1841
  if (webnovelGraphNetwork) {
@@ -1844,17 +2177,40 @@
1844
  }
1845
 
1846
  // 새 그래프 생성
1847
- createWebnovelGraphVisualization(webnovelAllGraphData, episodeFilter);
1848
  }
1849
 
1850
  function resetGraphView() {
1851
- if (webnovelGraphNetwork) {
1852
- webnovelGraphNetwork.fit({
1853
- animation: {
1854
- duration: 1000,
1855
- easingFunction: 'easeInOutQuad'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()">&times;</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()">&times;</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