diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 82ca1f03..c6207050 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -1630,6 +1630,7 @@ export default { sources: 'Source documents', recentActivity: 'Recent Activity', graphEmpty: 'Loading graph...', + fitView: 'Fit to View', logTitle: 'Activity Log', indexTitle: 'Index', graphNoData: 'No graph data yet. Upload documents first.', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index a0a7cc79..30db3267 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -2153,6 +2153,7 @@ export default { sources: "원본 문서", recentActivity: "최근 활동", graphEmpty: "그래프 로딩 중...", + fitView: "화면에 맞춤", logTitle: '활동 로그', indexTitle: '인덱스', graphNoData: '그래프 데이터가 없습니다. 먼저 문서를 업로드하세요.', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index e6a910a4..4a1055d5 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -1832,6 +1832,7 @@ export default { sources: 'Исходные документы', recentActivity: 'Последняя активность', graphEmpty: 'Загрузка графа...', + fitView: 'По размеру экрана', logTitle: 'Журнал активности', indexTitle: 'Индекс', graphNoData: 'Нет данных графа. Сначала загрузите документы.', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 6f0b44bf..242d5191 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -2138,6 +2138,7 @@ export default { sources: "来源文档", recentActivity: "最近活动", graphEmpty: "加载图谱中...", + fitView: "适应屏幕", logTitle: '日志', indexTitle: '索引', graphNoData: '暂无图谱数据,请先上传文档', diff --git a/frontend/src/views/knowledge/wiki/WikiBrowser.vue b/frontend/src/views/knowledge/wiki/WikiBrowser.vue index 8a97cbe0..341295f6 100644 --- a/frontend/src/views/knowledge/wiki/WikiBrowser.vue +++ b/frontend/src/views/knowledge/wiki/WikiBrowser.vue @@ -24,31 +24,57 @@
-
+
{{ $t('knowledgeEditor.wikiBrowser.filterSummary') }}
-
+
{{ $t('knowledgeEditor.wikiBrowser.filterEntity') }}
-
+
{{ $t('knowledgeEditor.wikiBrowser.filterConcept') }}
-
+
{{ $t('knowledgeEditor.wikiBrowser.filterSynthesis') }}
-
+
{{ $t('knowledgeEditor.wikiBrowser.filterComparison') }}
-
- → - {{ showArrows ? $t('knowledgeEditor.wikiBrowser.hideArrows') : $t('knowledgeEditor.wikiBrowser.showArrows') }} +
+
+ + {{ $t('knowledgeEditor.wikiBrowser.fitView') || '适应屏幕' }} +
+
+ → + {{ showArrows ? $t('knowledgeEditor.wikiBrowser.hideArrows') : $t('knowledgeEditor.wikiBrowser.showArrows') }} +
@@ -298,6 +324,107 @@ const loading = ref(false) const graphLoading = ref(false) const graphReady = ref(false) const showArrows = ref(true) + +// Graph filtering +const graphFilterTypes = ref>(new Set(['summary', 'entity', 'concept', 'synthesis', 'comparison', 'index', 'log'])) + +function toggleGraphFilterType(type: string) { + const newSet = new Set(graphFilterTypes.value) + if (newSet.has(type)) { + newSet.delete(type) + } else { + newSet.add(type) + } + graphFilterTypes.value = newSet + applyGraphFilters() +} + +function applyGraphFilters() { + if (!graphReady.value) return + + // Only show nodes whose type is in the active filter set + for (const { g, node } of graphNodeElsRef) { + if (graphFilterTypes.value.has(node.type)) { + g.style.display = '' + } else { + g.style.display = 'none' + } + } + + // Only show edges where BOTH source and target are visible + for (const { line, source, target } of graphEdgeElsRef) { + const sNode = graphNodes.find(n => n.slug === source) + const tNode = graphNodes.find(n => n.slug === target) + + if (sNode && tNode && graphFilterTypes.value.has(sNode.type) && graphFilterTypes.value.has(tNode.type)) { + line.style.display = '' + } else { + line.style.display = 'none' + } + } + + // Clear any existing highlight when filtering changes + if (graphHighlightSlug.value || graphSelectedSlug.value) { + const selectedStillVisible = graphSelectedSlug.value && + graphFilterTypes.value.has(graphNodes.find(n => n.slug === graphSelectedSlug.value)?.type || '') + + if (!selectedStillVisible) { + graphSelectedSlug.value = null + graphHighlightSlug.value = null + graphDrawerVisible.value = false + } + clearHighlight(graphNodeElsRef, graphEdgeElsRef) + } +} + +// Fit graph to view +function fitGraphToView() { + if (!graphReady.value || !graphPanZoomRef || !graphRef.value || graphNodes.length === 0) return + + const container = graphRef.value + const width = container.clientWidth + const height = container.clientHeight + + // Find bounding box of all VISIBLE nodes + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + let visibleCount = 0 + + for (const node of graphNodes) { + if (!graphFilterTypes.value.has(node.type)) continue + + minX = Math.min(minX, node.x) + minY = Math.min(minY, node.y) + maxX = Math.max(maxX, node.x) + maxY = Math.max(maxY, node.y) + visibleCount++ + } + + if (visibleCount === 0) return // No visible nodes + + // Calculate center of the bounding box + const cx = (minX + maxX) / 2 + const cy = (minY + maxY) / 2 + + // Calculate scale to fit the bounding box (with some padding) + const padding = 60 + const boxWidth = Math.max(maxX - minX, 100) + padding * 2 + const boxHeight = Math.max(maxY - minY, 100) + padding * 2 + + const scaleX = width / boxWidth + const scaleY = height / boxHeight + const targetScale = Math.max(0.2, Math.min(2, Math.min(scaleX, scaleY))) // Limit scale between 0.2 and 2 + + // Offset center if drawer is open + const targetCx = width / 2 - (graphDrawerVisible.value ? 240 : 0) + const targetCy = height / 2 + + // Target translation + const targetTx = targetCx - cx * targetScale + const targetTy = targetCy - cy * targetScale + + graphPanZoomRef.flyTo(targetTx, targetTy, targetScale, 600) +} + const collapsedGroups = reactive>({}) const graphDrawerVisible = ref(false) const graphDrawerPage = ref(null) @@ -680,8 +807,10 @@ function renderGraph() { return node }) - // Node radius based on link count - function nodeRadius(n: GNode) { return Math.max(6, Math.min(18, 6 + n.linkCount * 1.5)) } + // Node radius based on link count (logarithmic scale to prevent overly large nodes) + function nodeRadius(n: GNode) { + return Math.max(8, Math.min(24, 8 + Math.log(n.linkCount + 1) * 4)) + } // Define arrow markers in SVG const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs') @@ -814,6 +943,7 @@ function renderGraph() { text.setAttribute('font-size', '11') text.setAttribute('fill', 'var(--td-text-color-secondary)') text.setAttribute('pointer-events', 'none') + text.style.transition = 'opacity 0.2s' // Smooth fade in/out text.style.textShadow = '0 1px 3px var(--td-bg-color-container), 0 -1px 3px var(--td-bg-color-container), 1px 0 3px var(--td-bg-color-container), -1px 0 3px var(--td-bg-color-container)' text.textContent = n.title.length > 14 ? n.title.substring(0, 14) + '…' : n.title g.appendChild(text) @@ -882,19 +1012,36 @@ function renderGraph() { alpha *= 0.985 if (alpha < 0.005) { graphAnimFrame = 0; return } - // Repulsion (Barnes–Hut could optimize, but O(n²) is fine for < 500 nodes) - for (let i = 0; i < graphNodes.length; i++) { - if (graphNodes[i].pinned) continue - for (let j = i + 1; j < graphNodes.length; j++) { - let dx = graphNodes[j].x - graphNodes[i].x - let dy = graphNodes[j].y - graphNodes[i].y - const dist = Math.sqrt(dx * dx + dy * dy) || 1 + // Repulsion: Optimized using 1D spatial sorting (X-axis) to reduce O(n²) to O(n log n) + // This allows smooth rendering even for > 1000 nodes + const sortedNodes = [...graphNodes].sort((a, b) => a.x - b.x) + const MAX_REPULSION_DIST = 300 // Only calculate repulsion for nodes within 300px + const MAX_REPULSION_DIST_SQ = MAX_REPULSION_DIST * MAX_REPULSION_DIST + + for (let i = 0; i < sortedNodes.length; i++) { + const n1 = sortedNodes[i] + for (let j = i + 1; j < sortedNodes.length; j++) { + const n2 = sortedNodes[j] + const dx = n2.x - n1.x + + // Because nodes are sorted by X, if dx > MAX_REPULSION_DIST, + // all subsequent n2 nodes will also be too far on the X axis, so we can break early + if (dx > MAX_REPULSION_DIST) break + + const dy = n2.y - n1.y + if (Math.abs(dy) > MAX_REPULSION_DIST) continue // Too far on Y axis + + const distSq = dx * dx + dy * dy + if (distSq > MAX_REPULSION_DIST_SQ) continue + + const dist = Math.sqrt(distSq) || 1 // Prevent extremely high repulsion when nodes are very close - const force = (200 * alpha) / Math.max(dist * dist, 100) * 60 + const force = (200 * alpha) / Math.max(distSq, 100) * 60 const fx = (dx / dist) * force const fy = (dy / dist) * force - if (!graphNodes[i].pinned) { graphNodes[i].vx -= fx; graphNodes[i].vy -= fy } - if (!graphNodes[j].pinned) { graphNodes[j].vx += fx; graphNodes[j].vy += fy } + + if (!n1.pinned) { n1.vx -= fx; n1.vy -= fy } + if (!n2.pinned) { n2.vx += fx; n2.vy += fy } } } @@ -969,6 +1116,8 @@ function renderGraph() { graphEdgeElsRef = edgeEls.map(e => ({ line: e.line, source: e.source, target: e.target, bidir: e.bidir })) graphAdjacencyRef = adjacency + applyGraphFilters() + graphAnimFrame = requestAnimationFrame(tick) graphReady.value = true } @@ -1069,6 +1218,31 @@ function setupPanZoom(svg: SVGSVGElement, rootG: SVGGElement) { function applyTransform() { rootG.setAttribute('transform', `translate(${translateX},${translateY}) scale(${scale})`) + updateLabelsVisibility() + } + + function updateLabelsVisibility() { + // Hide labels when zoomed out too much or hide less important labels + // We only want to show labels for important nodes (high link count) when zoomed out + for (const { text, node } of graphNodeElsRef) { + if (node.slug === graphSelectedSlug.value || node.slug === graphHighlightSlug.value) { + text.style.opacity = '1' // Always show selected/highlighted + continue + } + + let visibilityThreshold = 0.5 // Default: need to zoom in to at least 0.5 to see all labels + + // Highly connected nodes get their labels shown earlier + if (node.linkCount > 10) visibilityThreshold = 0.2 + else if (node.linkCount > 5) visibilityThreshold = 0.35 + else if (node.linkCount > 2) visibilityThreshold = 0.45 + + if (scale < visibilityThreshold) { + text.style.opacity = '0' + } else { + text.style.opacity = '1' + } + } } // Export methods for programmatic pan/zoom @@ -1164,21 +1338,25 @@ function applyHighlight( const neighbors = adjacency.get(slug) || new Set() const hoverNeighbors = hoverSlug ? (adjacency.get(hoverSlug) || new Set()) : new Set() + // Helper to get consistent radius + const getRadius = (n: GNode) => Math.max(8, Math.min(24, 8 + Math.log(n.linkCount + 1) * 4)) + for (const { g, circle, activeRing, node } of nodeEls) { + const r = getRadius(node) if (node.slug === slug) { - circle.setAttribute('r', String(Math.max(6, Math.min(18, 6 + node.linkCount * 1.5)) + 3)) + circle.setAttribute('r', String(r + 3)) circle.setAttribute('stroke-width', '3') g.style.opacity = '1' } else if (hoverSlug && node.slug === hoverSlug) { - circle.setAttribute('r', String(Math.max(6, Math.min(18, 6 + node.linkCount * 1.5)) + 3)) + circle.setAttribute('r', String(r + 3)) circle.setAttribute('stroke-width', '3') g.style.opacity = '1' } else if (neighbors.has(node.slug) || (hoverSlug && hoverNeighbors.has(node.slug))) { - circle.setAttribute('r', String(Math.max(6, Math.min(18, 6 + node.linkCount * 1.5)))) + circle.setAttribute('r', String(r)) circle.setAttribute('stroke-width', '2') g.style.opacity = '1' } else { - circle.setAttribute('r', String(Math.max(6, Math.min(18, 6 + node.linkCount * 1.5)))) + circle.setAttribute('r', String(r)) circle.setAttribute('stroke-width', '2') g.style.opacity = '0.2' } @@ -1222,8 +1400,10 @@ function clearHighlight( return } + const getRadius = (n: GNode) => Math.max(8, Math.min(24, 8 + Math.log(n.linkCount + 1) * 4)) + for (const { g, circle, activeRing, node } of nodeEls) { - circle.setAttribute('r', String(Math.max(6, Math.min(18, 6 + node.linkCount * 1.5)))) + circle.setAttribute('r', String(getRadius(node))) circle.setAttribute('stroke-width', '2') g.style.opacity = '1' activeRing.style.opacity = '0' @@ -1255,6 +1435,15 @@ function handleGraphSearchSelect(value: string) { // Find node coordinates const node = graphNodes.find(n => n.slug === value) + + // If the node's type is currently filtered out, re-enable it so it becomes visible + if (node && !graphFilterTypes.value.has(node.type)) { + const newSet = new Set(graphFilterTypes.value) + newSet.add(node.type) + graphFilterTypes.value = newSet + applyGraphFilters() + } + if (node && graphPanZoomRef) { const container = graphRef.value if (container) { @@ -1870,6 +2059,21 @@ onUnmounted(() => { gap: 8px; font-size: 11px; color: var(--td-text-color-secondary); + + &.clickable { + cursor: pointer; + transition: all 0.15s; + + &:hover { + color: var(--td-text-color-primary); + } + } + + &.disabled { + color: var(--td-text-color-placeholder); + text-decoration: line-through; + opacity: 0.5; + } } .legend-dot { @@ -1886,6 +2090,12 @@ onUnmounted(() => { margin: 0 -12px; } +.legend-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + .legend-action { display: flex; align-items: center;