ltcai 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,10 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
21
21
  local_indexed: '지식 그래프 생성 완료', local_watch_unavailable: '자동 감지는 watchdog 설치 후 작동합니다.',
22
22
  detail_empty: '노드를 클릭하면 요약, 중요도, 연결 강도, 메타데이터를 볼 수 있습니다. 검색 패널에서는 서버 검색 결과를 기준으로 더 정확하게 이동할 수 있습니다.',
23
23
  detail_empty_short: '노드를 클릭하면 요약, 중요도, 메타데이터를 볼 수 있습니다.',
24
- refresh: '새로고침', error: '오류', graph_load_fail: '그래프를 불러오지 못했습니다.', graph_refresh_fail: '그래프를 새로고침하지 못했습니다.',
24
+ refresh: '새로고침', fit: '맞춤', expand: '확장', collapse: '접기', focus: '포커스', path: '경로',
25
+ clear_focus: '포커스 해제', path_start: '경로 시작 지정', path_ready: '경로 시작: {title}', path_pick_target: '도착 노드를 선택한 뒤 경로를 누르세요.',
26
+ path_not_found: '두 노드 사이의 경로를 찾지 못했습니다.', source_open: '소스 열기',
27
+ error: '오류', graph_load_fail: '그래프를 불러오지 못했습니다.', graph_refresh_fail: '그래프를 새로고침하지 못했습니다.',
25
28
  no_node_types: '아직 노드 유형이 없습니다.', no_relationships: '아직 관계가 없습니다.',
26
29
  open_in_chat: '채팅에서 열기', today: '오늘', day_ago: '1일 전', days_ago: '{n}일 전', months_ago: '{n}개월 전', years_ago: '{n}년 전',
27
30
  },
@@ -43,7 +46,10 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
43
46
  local_indexed: 'Knowledge graph built', local_watch_unavailable: 'Auto watch works after watchdog is installed.',
44
47
  detail_empty: 'Click a node to see its summary, importance, connection strength, and metadata. Search results can jump to more precise nodes.',
45
48
  detail_empty_short: 'Click a node to see its summary, importance, and metadata.',
46
- refresh: 'Refresh', error: 'Error', graph_load_fail: 'Could not load the graph.', graph_refresh_fail: 'Could not refresh the graph.',
49
+ refresh: 'Refresh', fit: 'Fit', expand: 'Expand', collapse: 'Collapse', focus: 'Focus', path: 'Path',
50
+ clear_focus: 'Clear focus', path_start: 'Set path start', path_ready: 'Path start: {title}', path_pick_target: 'Select a destination node, then press Path.',
51
+ path_not_found: 'No path found between those nodes.', source_open: 'Open source',
52
+ error: 'Error', graph_load_fail: 'Could not load the graph.', graph_refresh_fail: 'Could not refresh the graph.',
47
53
  no_node_types: 'No node types yet.', no_relationships: 'No relationships yet.',
48
54
  open_in_chat: 'Open in chat', today: 'today', day_ago: '1 day ago', days_ago: '{n} days ago', months_ago: '{n} mo ago', years_ago: '{n} yr ago',
49
55
  }
@@ -73,7 +79,18 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
73
79
  document.getElementById('local-source-label').textContent = t('local_sources');
74
80
  document.getElementById('edge-label').textContent = t('relationship_legend');
75
81
  document.getElementById('type-label').textContent = t('node_types');
76
- document.getElementById('refresh-btn').textContent = `↺ ${t('refresh')}`;
82
+ const toolbarLabels = {
83
+ 'refresh-btn': ['ti-refresh', t('refresh')],
84
+ 'fit-btn': ['ti-arrows-maximize', t('fit')],
85
+ 'expand-btn': ['ti-circle-plus', t('expand')],
86
+ 'collapse-btn': ['ti-circle-minus', t('collapse')],
87
+ 'focus-btn': ['ti-focus-2', focusNodeId ? t('clear_focus') : t('focus')],
88
+ 'path-btn': ['ti-route', t('path')],
89
+ };
90
+ Object.entries(toolbarLabels).forEach(([id, [icon, label]]) => {
91
+ const btn = document.getElementById(id);
92
+ if (btn) btn.innerHTML = `<i class="ti ${icon}"></i> ${label}`;
93
+ });
77
94
  const langBtn = document.getElementById('graph-lang-btn');
78
95
  if (langBtn) langBtn.textContent = `Language: ${currentLang === 'ko' ? '한국어' : 'English'}`;
79
96
  ['ko', 'en'].forEach(lang => {
@@ -174,6 +191,13 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
174
191
  let height = 0;
175
192
  let searchResults = [];
176
193
  let searchResultIds = new Set();
194
+ let expandedNodeIds = new Set();
195
+ let hiddenNodeIds = new Set();
196
+ let focusNodeId = null;
197
+ let focusDepth = 2;
198
+ let pathStartId = null;
199
+ let pathNodeIds = new Set();
200
+ let pathEdgeKeys = new Set();
177
201
  let searchAbortController = null;
178
202
  let searchDebounceId = null;
179
203
  let localState = {
@@ -570,13 +594,55 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
570
594
  return counts;
571
595
  }
572
596
 
597
+ function edgeKey(edgeOrFrom, to) {
598
+ if (typeof edgeOrFrom === 'object') {
599
+ return `${edgeOrFrom.from}|${edgeOrFrom.to}`;
600
+ }
601
+ return `${edgeOrFrom}|${to}`;
602
+ }
603
+
604
+ function computeSubgraphIds(rootId, depth = 2) {
605
+ if (!rootId) return null;
606
+ const adjacency = new Map();
607
+ rawGraph.edges.forEach(edge => {
608
+ if (!edge.from || !edge.to) return;
609
+ if (!adjacency.has(edge.from)) adjacency.set(edge.from, new Set());
610
+ if (!adjacency.has(edge.to)) adjacency.set(edge.to, new Set());
611
+ adjacency.get(edge.from).add(edge.to);
612
+ adjacency.get(edge.to).add(edge.from);
613
+ });
614
+ const visible = new Set([rootId]);
615
+ let frontier = new Set([rootId]);
616
+ for (let i = 0; i < depth; i++) {
617
+ const next = new Set();
618
+ frontier.forEach(id => {
619
+ (adjacency.get(id) || []).forEach(neighbor => {
620
+ if (!visible.has(neighbor)) {
621
+ visible.add(neighbor);
622
+ next.add(neighbor);
623
+ }
624
+ });
625
+ });
626
+ frontier = next;
627
+ }
628
+ pathNodeIds.forEach(id => visible.add(id));
629
+ return visible;
630
+ }
631
+
573
632
  function applyFilter() {
574
- graph.nodes = rawGraph.nodes.filter(node => !hiddenTypes.has(node.type));
633
+ const focusIds = computeSubgraphIds(focusNodeId, focusDepth);
634
+ graph.nodes = rawGraph.nodes.filter(node => {
635
+ if (hiddenTypes.has(node.type)) return false;
636
+ if (focusIds && !focusIds.has(node.id)) return false;
637
+ if (hiddenNodeIds.has(node.id) && node.id !== selected?.id && node.id !== focusNodeId && !pathNodeIds.has(node.id)) return false;
638
+ return true;
639
+ });
575
640
  const nodeSet = new Set(graph.nodes.map(node => node.id));
576
641
  const byId = Object.fromEntries(rawGraph.nodes.map(node => [node.id, node]));
577
642
  graph.edges = rawGraph.edges
578
643
  .filter(edge => nodeSet.has(edge.from) && nodeSet.has(edge.to))
579
644
  .map(edge => ({ ...edge, source: byId[edge.from], target: byId[edge.to] }));
645
+ renderFocusChip();
580
646
  }
581
647
 
582
648
  function seedLayout() {
@@ -698,8 +764,14 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
698
764
  updateStats();
699
765
  renderTypeFilters(stats.nodes || buildTypeCounts());
700
766
  renderEdgeLegend(stats.edges || {});
701
- showDetail(selected && rawGraph.nodes.find(node => node.id === selected.id) || graph.nodes[0] || null);
767
+ const urlNode = new URLSearchParams(window.location.search).get('node');
768
+ const initialNode = (urlNode && rawGraph.nodes.find(node => node.id === urlNode))
769
+ || (selected && rawGraph.nodes.find(node => node.id === selected.id))
770
+ || graph.nodes[0]
771
+ || null;
702
772
  cam = { scale: 1, tx: 0, ty: 0 };
773
+ showDetail(initialNode);
774
+ if (initialNode && urlNode) centerOnNode(initialNode, Math.max(cam.scale, 1));
703
775
  wakeUp();
704
776
  }
705
777
 
@@ -755,6 +827,29 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
755
827
  }
756
828
  window.toggleType = toggleType;
757
829
 
830
+ function renderFocusChip() {
831
+ const chip = document.getElementById('graph-focus-chip');
832
+ if (!chip) return;
833
+ const focusNode = rawGraph.nodes.find(node => node.id === focusNodeId);
834
+ const pathStart = rawGraph.nodes.find(node => node.id === pathStartId);
835
+ const parts = [];
836
+ if (focusNode) parts.push(`<span><i class="ti ti-focus-2"></i>${escapeHtml(focusNode.title || focusNode.id)}</span>`);
837
+ if (pathStart) parts.push(`<span><i class="ti ti-route"></i>${escapeHtml(t('path_ready').replace('{title}', pathStart.title || pathStart.id))}</span>`);
838
+ if (pathNodeIds.size) parts.push(`<span>${pathNodeIds.size} nodes</span>`);
839
+ chip.hidden = parts.length === 0;
840
+ chip.innerHTML = parts.join('');
841
+ applyI18n();
842
+ }
843
+
844
+ function relatedNodeIds(nodeId) {
845
+ const ids = new Set();
846
+ rawGraph.edges.forEach(edge => {
847
+ if (edge.from === nodeId) ids.add(edge.to);
848
+ if (edge.to === nodeId) ids.add(edge.from);
849
+ });
850
+ return ids;
851
+ }
852
+
758
853
  function step() {
759
854
  const nodes = graph.nodes;
760
855
  const edges = graph.edges;
@@ -841,13 +936,14 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
841
936
  if (!edge.source || !edge.target) return;
842
937
  const style = edgeStyle(edge.type);
843
938
  const isNeighborEdge = neighborSet && neighborSet.has(edge.from) && neighborSet.has(edge.to);
844
- const baseAlpha = neighborSet ? (isNeighborEdge ? 0.88 : 0.07) : 0.34;
845
- const widthBoost = isNeighborEdge ? 0.5 : 0;
939
+ const isPathEdge = pathEdgeKeys.has(edgeKey(edge)) || pathEdgeKeys.has(edgeKey(edge.to, edge.from));
940
+ const baseAlpha = isPathEdge ? 0.98 : (neighborSet ? (isNeighborEdge ? 0.88 : 0.07) : 0.34);
941
+ const widthBoost = isPathEdge ? 2.2 : (isNeighborEdge ? 0.5 : 0);
846
942
  ctx.save();
847
943
  ctx.globalAlpha = baseAlpha;
848
- ctx.strokeStyle = style.color;
944
+ ctx.strokeStyle = isPathEdge ? '#f59e0b' : style.color;
849
945
  ctx.lineWidth = (style.width + Math.min(3.4, (edge.weight || 1) * 1.1) + widthBoost) / cam.scale;
850
- ctx.setLineDash(style.dash || []);
946
+ ctx.setLineDash(isPathEdge ? [] : (style.dash || []));
851
947
  ctx.beginPath();
852
948
  ctx.moveTo(edge.source.x, edge.source.y);
853
949
  ctx.lineTo(edge.target.x, edge.target.y);
@@ -858,10 +954,11 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
858
954
  graph.nodes.forEach(node => {
859
955
  const isNeighbor = neighborSet ? neighborSet.has(node.id) : true;
860
956
  const isSearchHit = searchResultIds.has(node.id);
957
+ const isPathNode = pathNodeIds.has(node.id);
861
958
  const isSelected = node === selected;
862
959
  const isHovered = node === hovered;
863
960
  const alpha = neighborSet ? (isNeighbor ? 1 : 0.12) : 1;
864
- const radius = node.r + (isSelected ? 4 : isHovered ? 2 : isSearchHit ? 2.6 : 0);
961
+ const radius = node.r + (isSelected ? 4 : isHovered ? 2 : isPathNode ? 3.5 : isSearchHit ? 2.6 : 0);
865
962
 
866
963
  ctx.globalAlpha = alpha;
867
964
 
@@ -890,9 +987,9 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
890
987
  ctx.stroke();
891
988
 
892
989
  // 선택/호버 외곽 링
893
- if (isSelected || isHovered || isSearchHit) {
894
- ctx.strokeStyle = isSelected ? '#6f42e8' : nodeColor(node.type);
895
- ctx.lineWidth = (isSelected ? 2.8 : 1.8) / cam.scale;
990
+ if (isSelected || isHovered || isSearchHit || isPathNode) {
991
+ ctx.strokeStyle = isPathNode ? '#f59e0b' : (isSelected ? '#6f42e8' : nodeColor(node.type));
992
+ ctx.lineWidth = (isSelected || isPathNode ? 2.8 : 1.8) / cam.scale;
896
993
  ctx.globalAlpha = alpha * 0.55;
897
994
  ctx.beginPath();
898
995
  ctx.arc(node.x, node.y, radius + 5 / cam.scale, 0, Math.PI * 2);
@@ -979,6 +1076,123 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
979
1076
  wakeUp();
980
1077
  }
981
1078
 
1079
+ async function expandNode(node = selected) {
1080
+ if (!node) return;
1081
+ const res = await apiFetch(`/knowledge-graph/neighbors/${encodeURIComponent(node.id)}`);
1082
+ if (!res.ok) throw new Error(`Expand failed (${res.status})`);
1083
+ const payload = await res.json();
1084
+ const nodes = [
1085
+ payload.node || node,
1086
+ ...((payload.neighbors || []).map(item => ({ ...item, updated_at: item.updated_at }))),
1087
+ ];
1088
+ expandedNodeIds.add(node.id);
1089
+ nodes.forEach(item => hiddenNodeIds.delete(item.id));
1090
+ mergeGraphData(nodes, payload.edges || []);
1091
+ showDetail(rawGraph.nodes.find(item => item.id === node.id) || node);
1092
+ centerOnNode(node, Math.max(cam.scale, 1));
1093
+ }
1094
+
1095
+ function collapseNode(node = selected) {
1096
+ if (!node) return;
1097
+ relatedNodeIds(node.id).forEach(id => {
1098
+ if (id !== node.id && id !== focusNodeId && !pathNodeIds.has(id)) hiddenNodeIds.add(id);
1099
+ });
1100
+ expandedNodeIds.delete(node.id);
1101
+ applyFilter();
1102
+ showDetail(node);
1103
+ wakeUp();
1104
+ }
1105
+
1106
+ function toggleFocus(node = selected) {
1107
+ if (!node) return;
1108
+ if (focusNodeId === node.id) {
1109
+ focusNodeId = null;
1110
+ } else {
1111
+ focusNodeId = node.id;
1112
+ hiddenNodeIds.delete(node.id);
1113
+ }
1114
+ applyFilter();
1115
+ centerOnNode(node, Math.max(cam.scale, 0.9));
1116
+ }
1117
+
1118
+ function localShortestPath(startId, targetId) {
1119
+ if (!startId || !targetId || startId === targetId) return startId ? [startId] : [];
1120
+ const adjacency = new Map();
1121
+ rawGraph.edges.forEach(edge => {
1122
+ if (!edge.from || !edge.to) return;
1123
+ if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
1124
+ if (!adjacency.has(edge.to)) adjacency.set(edge.to, []);
1125
+ adjacency.get(edge.from).push(edge.to);
1126
+ adjacency.get(edge.to).push(edge.from);
1127
+ });
1128
+ const queue = [[startId]];
1129
+ const seen = new Set([startId]);
1130
+ while (queue.length) {
1131
+ const path = queue.shift();
1132
+ const last = path[path.length - 1];
1133
+ if (last === targetId) return path;
1134
+ (adjacency.get(last) || []).forEach(next => {
1135
+ if (!seen.has(next)) {
1136
+ seen.add(next);
1137
+ queue.push([...path, next]);
1138
+ }
1139
+ });
1140
+ }
1141
+ return [];
1142
+ }
1143
+
1144
+ async function showShortestPath(target = selected) {
1145
+ if (!target) return;
1146
+ if (!pathStartId) {
1147
+ pathStartId = target.id;
1148
+ renderFocusChip();
1149
+ showDetail(target);
1150
+ return;
1151
+ }
1152
+ let path = localShortestPath(pathStartId, target.id);
1153
+ if (!path.length || path[path.length - 1] !== target.id) {
1154
+ const res = await apiFetch(`/workspace/relationships/${encodeURIComponent(pathStartId)}?target_id=${encodeURIComponent(target.id)}`);
1155
+ if (res.ok) {
1156
+ const payload = await res.json();
1157
+ path = Array.isArray(payload.shortest_path) ? payload.shortest_path : path;
1158
+ }
1159
+ }
1160
+ if (!path.length || path[path.length - 1] !== target.id) {
1161
+ searchCountEl.textContent = t('path_not_found');
1162
+ return;
1163
+ }
1164
+ pathNodeIds = new Set(path);
1165
+ pathEdgeKeys = new Set();
1166
+ for (let i = 0; i < path.length - 1; i++) {
1167
+ pathEdgeKeys.add(edgeKey(path[i], path[i + 1]));
1168
+ pathEdgeKeys.add(edgeKey(path[i + 1], path[i]));
1169
+ }
1170
+ path.forEach(id => hiddenNodeIds.delete(id));
1171
+ applyFilter();
1172
+ const first = rawGraph.nodes.find(node => node.id === path[0]);
1173
+ const last = rawGraph.nodes.find(node => node.id === path[path.length - 1]);
1174
+ if (first && last) {
1175
+ const x0 = Math.min(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.x ?? first.x));
1176
+ const x1 = Math.max(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.x ?? first.x));
1177
+ const y0 = Math.min(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.y ?? first.y));
1178
+ const y1 = Math.max(...path.map(id => rawGraph.nodes.find(node => node.id === id)?.y ?? first.y));
1179
+ cam.scale = clamp(Math.min(width / Math.max(260, x1 - x0 + 180), height / Math.max(220, y1 - y0 + 160)), 0.4, 2.4);
1180
+ cam.tx = width / 2 - ((x0 + x1) / 2) * cam.scale;
1181
+ cam.ty = height / 2 - ((y0 + y1) / 2) * cam.scale;
1182
+ }
1183
+ showDetail(target);
1184
+ wakeUp();
1185
+ }
1186
+
1187
+ function clearPath() {
1188
+ pathStartId = selected?.id || null;
1189
+ pathNodeIds = new Set();
1190
+ pathEdgeKeys = new Set();
1191
+ renderFocusChip();
1192
+ applyFilter();
1193
+ wakeUp();
1194
+ }
1195
+
982
1196
  function metricCards(node) {
983
1197
  const metrics = ((node.metadata || {}).graph_metrics) || {};
984
1198
  const cards = [
@@ -1010,25 +1224,74 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
1010
1224
  selected = node;
1011
1225
  const meta = node.metadata || {};
1012
1226
  const convId = meta.conversation_id;
1227
+ const sourcePath = meta.path || meta.absolute_path || meta.root_path || meta.source_path || '';
1013
1228
  const jumpHtml = convId
1014
1229
  ? `<a class="jump-btn" href="${API_BASE}/chat?open_conversation=${encodeURIComponent(convId)}">${t('open_in_chat')}</a>`
1015
1230
  : '';
1231
+ const sourceHtml = sourcePath
1232
+ ? `<a class="jump-btn secondary" href="${API_BASE}/local/serve?path=${encodeURIComponent(sourcePath)}">${t('source_open')}</a>`
1233
+ : '';
1016
1234
  const metrics = metricCards(node);
1017
1235
  const updatedAt = formatUpdatedAt(node.updated_at);
1018
1236
  const source = meta.relative_path || meta.filename || meta.conversation_id || meta.source || '';
1019
1237
  const metadataStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
1238
+ const relatedRows = rawGraph.edges
1239
+ .filter(edge => edge.from === node.id || edge.to === node.id)
1240
+ .slice(0, 10)
1241
+ .map(edge => {
1242
+ const otherId = edge.from === node.id ? edge.to : edge.from;
1243
+ const other = rawGraph.nodes.find(item => item.id === otherId) || { id: otherId, title: otherId, type: 'Event' };
1244
+ const direction = edge.from === node.id ? '→' : '←';
1245
+ return `
1246
+ <button class="related-node-btn" data-detail-node="${escapeHtml(otherId)}">
1247
+ <span>${direction}</span>
1248
+ <strong>${escapeHtml(other.title || other.id)}</strong>
1249
+ <em>${escapeHtml(edge.type || 'related')}</em>
1250
+ </button>
1251
+ `;
1252
+ }).join('');
1020
1253
  detail.innerHTML = `
1021
1254
  <div class="type-badge" style="background:${nodeColor(node.type)}">${escapeHtml(typeLabel(node.type))}</div>
1022
1255
  <div class="detail-title">${escapeHtml(node.title || node.id)}</div>
1023
1256
  ${node.summary ? `<div class="detail-summary">${escapeHtml(node.summary)}</div>` : ''}
1024
- ${jumpHtml}
1257
+ <div class="detail-actions">
1258
+ ${jumpHtml}
1259
+ ${sourceHtml}
1260
+ <button class="jump-btn secondary" data-graph-action="expand">${t('expand')}</button>
1261
+ <button class="jump-btn secondary" data-graph-action="focus">${focusNodeId === node.id ? t('clear_focus') : t('focus')}</button>
1262
+ <button class="jump-btn secondary" data-graph-action="path-start">${t('path_start')}</button>
1263
+ </div>
1025
1264
  ${metrics}
1026
1265
  <div class="detail-summary">
1027
1266
  ${source ? `<strong>source:</strong> ${escapeHtml(source)}<br>` : ''}
1028
1267
  ${updatedAt ? `<strong>updated:</strong> ${escapeHtml(updatedAt)}` : ''}
1029
1268
  </div>
1269
+ ${relatedRows ? `<div class="related-node-list">${relatedRows}</div>` : ''}
1030
1270
  ${metadataStr ? `<div class="meta-block">${escapeHtml(metadataStr)}</div>` : ''}
1031
1271
  `;
1272
+ detail.querySelectorAll('[data-graph-action]').forEach(btn => {
1273
+ btn.addEventListener('click', () => {
1274
+ const action = btn.dataset.graphAction;
1275
+ if (action === 'expand') expandNode(node).catch(error => { searchCountEl.textContent = error.message; });
1276
+ if (action === 'focus') toggleFocus(node);
1277
+ if (action === 'path-start') {
1278
+ pathStartId = node.id;
1279
+ pathNodeIds = new Set();
1280
+ pathEdgeKeys = new Set();
1281
+ renderFocusChip();
1282
+ wakeUp();
1283
+ }
1284
+ });
1285
+ });
1286
+ detail.querySelectorAll('[data-detail-node]').forEach(btn => {
1287
+ btn.addEventListener('click', () => {
1288
+ const next = rawGraph.nodes.find(item => item.id === btn.dataset.detailNode);
1289
+ if (next) {
1290
+ showDetail(next);
1291
+ centerOnNode(next, Math.max(cam.scale, 0.95));
1292
+ }
1293
+ });
1294
+ });
1032
1295
  wakeUp();
1033
1296
  }
1034
1297
 
@@ -1047,6 +1310,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
1047
1310
  }
1048
1311
 
1049
1312
  function renderSearchResults() {
1313
+ document.querySelector('.search-shell')?.classList.toggle('search-open', Boolean(searchInput.value.trim()));
1050
1314
  if (!searchInput.value.trim()) {
1051
1315
  searchResultsEl.innerHTML = `<p class="search-empty">${t('search_empty')}</p>`;
1052
1316
  return;
@@ -1240,6 +1504,14 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
1240
1504
  wakeUp();
1241
1505
  }, { passive: false });
1242
1506
 
1507
+ canvas.addEventListener('dblclick', event => {
1508
+ const rect = canvas.getBoundingClientRect();
1509
+ const node = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
1510
+ if (node) expandNode(node).catch(error => {
1511
+ searchCountEl.textContent = error.message;
1512
+ });
1513
+ });
1514
+
1243
1515
  let lastTouchDistance = null;
1244
1516
  canvas.addEventListener('touchstart', event => {
1245
1517
  event.preventDefault();
@@ -1320,6 +1592,11 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
1320
1592
  });
1321
1593
 
1322
1594
  document.getElementById('clear-search-btn').addEventListener('click', clearSearch);
1595
+ document.getElementById('fit-btn').addEventListener('click', fitToScreen);
1596
+ document.getElementById('expand-btn').addEventListener('click', () => expandNode().catch(error => { searchCountEl.textContent = error.message; }));
1597
+ document.getElementById('collapse-btn').addEventListener('click', () => collapseNode());
1598
+ document.getElementById('focus-btn').addEventListener('click', () => toggleFocus());
1599
+ document.getElementById('path-btn').addEventListener('click', () => showShortestPath().catch(error => { searchCountEl.textContent = error.message; }));
1323
1600
  document.addEventListener('click', event => {
1324
1601
  if (!event.target.closest('.lang-picker')) {
1325
1602
  document.querySelectorAll('.lang-picker-menu').forEach(menu => menu.classList.remove('open'));
@@ -1329,6 +1606,11 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
1329
1606
  rawGraph = { nodes: [], edges: [] };
1330
1607
  graph = { nodes: [], edges: [] };
1331
1608
  selected = null;
1609
+ focusNodeId = null;
1610
+ pathStartId = null;
1611
+ pathNodeIds = new Set();
1612
+ pathEdgeKeys = new Set();
1613
+ hiddenNodeIds = new Set();
1332
1614
  loadGraph().catch(error => {
1333
1615
  detail.innerHTML = `<div class="type-badge" style="background:${nodeColor('ClearEvent')}; color:#091019">${t('error')}</div><div class="detail-title">${t('graph_refresh_fail')}</div><div class="detail-summary">${escapeHtml(error.message)}</div>`;
1334
1616
  });