From 8dd50de8d44a3e7fe8a693aca623eff7b4d42d48 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Thu, 9 Apr 2026 17:07:14 +0800 Subject: [PATCH] feat: Add "Fit to View" functionality and enhance graph filtering in WikiBrowser - Introduced a new "Fit to View" action in the WikiBrowser component, allowing users to automatically adjust the graph display to fit the visible area. - Implemented graph filtering capabilities, enabling users to toggle the visibility of different node types, improving the overall interactivity and usability of the graph interface. - Updated localization files for English, Korean, Russian, and Chinese to include the new "Fit to View" message, ensuring consistent user experience across languages. These enhancements significantly improve the user experience by providing better control over graph visualization and interaction. --- frontend/src/i18n/locales/en-US.ts | 1 + frontend/src/i18n/locales/ko-KR.ts | 1 + frontend/src/i18n/locales/ru-RU.ts | 1 + frontend/src/i18n/locales/zh-CN.ts | 1 + .../src/views/knowledge/wiki/WikiBrowser.vue | 260 ++++++++++++++++-- 5 files changed, 239 insertions(+), 25 deletions(-) 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;