@@ -24,31 +24,57 @@
<!-- Legend Overlay -- >
< div v-if = "graphReady" class="wiki-graph-legend" >
< div class = "legend-items" >
< div class = "legend-item" >
< div
class = "legend-item clickable"
: class = "{ disabled: !graphFilterTypes.has('summary') }"
@ click = "toggleGraphFilterType('summary')"
>
< span class = "legend-dot" style = "background: #0052d9" > < / span >
{ { $t ( 'knowledgeEditor.wikiBrowser.filterSummary' ) } }
< / div >
< div class = "legend-item" >
< div
class = "legend-item clickable"
: class = "{ disabled: !graphFilterTypes.has('entity') }"
@ click = "toggleGraphFilterType('entity')"
>
< span class = "legend-dot" style = "background: #2ba471" > < / span >
{ { $t ( 'knowledgeEditor.wikiBrowser.filterEntity' ) } }
< / div >
< div class = "legend-item" >
< div
class = "legend-item clickable"
: class = "{ disabled: !graphFilterTypes.has('concept') }"
@ click = "toggleGraphFilterType('concept')"
>
< span class = "legend-dot" style = "background: #e37318" > < / span >
{ { $t ( 'knowledgeEditor.wikiBrowser.filterConcept' ) } }
< / div >
< div class = "legend-item" >
< div
class = "legend-item clickable"
: class = "{ disabled: !graphFilterTypes.has('synthesis') }"
@ click = "toggleGraphFilterType('synthesis')"
>
< span class = "legend-dot" style = "background: #0594fa" > < / span >
{ { $t ( 'knowledgeEditor.wikiBrowser.filterSynthesis' ) } }
< / div >
< div class = "legend-item" >
< div
class = "legend-item clickable"
: class = "{ disabled: !graphFilterTypes.has('comparison') }"
@ click = "toggleGraphFilterType('comparison')"
>
< span class = "legend-dot" style = "background: #d54941" > < / span >
{ { $t ( 'knowledgeEditor.wikiBrowser.filterComparison' ) } }
< / div >
< / div >
< div class = "legend-divider" > < / div >
< div class = "legend-action" @click ="toggleArrows" : class = "{ active: showArrows } ">
< span class = "legend-action-icon" > → < / span >
< span > { { showArrows ? $t ( 'know led geEditor.wikiBrowser.hideArrows' ) : $t ( 'knowledgeEditor.wikiBrowser.showArrows' ) } } < / span >
< div class = "legend-actions " >
< div class = "legend-action" @click ="fitGraphToView" title = "Fit to View" >
< span class = " legend-action-icon" > < t-icon name = "focus" / > < / span >
< span > { { $t ( 'knowledgeEditor.wikiBrowser.fitView' ) || '适应屏幕' } } < / span >
< / div >
< div class = "legend-action" @click ="toggleArrows" : class = "{ active: showArrows }" >
< span class = "legend-action-icon" > → < / span >
< span > { { showArrows ? $t ( 'knowledgeEditor.wikiBrowser.hideArrows' ) : $t ( 'knowledgeEditor.wikiBrowser.showArrows' ) } } < / span >
< / div >
< / div >
< / div >
@@ -298,6 +324,107 @@ const loading = ref(false)
const graphLoading = ref ( false )
const graphReady = ref ( false )
const showArrows = ref ( true )
/ / G r a p h f i l t e r i n g
const graphFilterTypes = ref < Set < string > > ( 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
/ / O n l y s h o w n o d e s w h o s e t y p e i s i n t h e a c t i v e f i l t e r s e t
for ( const { g , node } of graphNodeElsRef ) {
if ( graphFilterTypes . value . has ( node . type ) ) {
g . style . display = ''
} else {
g . style . display = 'none'
}
}
/ / O n l y s h o w e d g e s w h e r e B O T H s o u r c e a n d t a r g e t a r e v i s i b l e
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'
}
}
/ / C l e a r a n y e x i s t i n g h i g h l i g h t w h e n f i l t e r i n g c h a n g e s
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 )
}
}
/ / F i t g r a p h t o v i e w
function fitGraphToView ( ) {
if ( ! graphReady . value || ! graphPanZoomRef || ! graphRef . value || graphNodes . length === 0 ) return
const container = graphRef . value
const width = container . clientWidth
const height = container . clientHeight
/ / F i n d b o u n d i n g b o x o f a l l V I S I B L E n o d e s
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 / / N o v i s i b l e n o d e s
/ / C a l c u l a t e c e n t e r o f t h e b o u n d i n g b o x
const cx = ( minX + maxX ) / 2
const cy = ( minY + maxY ) / 2
/ / C a l c u l a t e s c a l e t o f i t t h e b o u n d i n g b o x ( w i t h s o m e p a d d i n g )
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 ) ) ) / / L i m i t s c a l e b e t w e e n 0 . 2 a n d 2
/ / O f f s e t c e n t e r i f d r a w e r i s o p e n
const targetCx = width / 2 - ( graphDrawerVisible . value ? 240 : 0 )
const targetCy = height / 2
/ / T a r g e t t r a n s l a t i o n
const targetTx = targetCx - cx * targetScale
const targetTy = targetCy - cy * targetScale
graphPanZoomRef . flyTo ( targetTx , targetTy , targetScale , 600 )
}
const collapsedGroups = reactive < Record < string , boolean > > ( { } )
const graphDrawerVisible = ref ( false )
const graphDrawerPage = ref < WikiPage | null > ( null )
@@ -680,8 +807,10 @@ function renderGraph() {
return node
} )
/ / N o d e r a d i u s b a s e d o n l i n k c o u n t
function nodeRadius ( n : GNode ) { return Math . max ( 6 , Math . min ( 18 , 6 + n . linkCount * 1.5 ) ) }
/ / N o d e r a d i u s b a s e d o n l i n k c o u n t ( l o g a r i t h m i c s c a l e t o p r e v e n t o v e r l y l a r g e n o d e s )
function nodeRadius ( n : GNode ) {
return Math . max ( 8 , Math . min ( 24 , 8 + Math . log ( n . linkCount + 1 ) * 4 ) )
}
/ / D e f i n e a r r o w m a r k e r s i n S V G < d e f s >
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' / / S m o o t h f a d e i n / o u t
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 }
/ / R e p u l s i o n ( B a r n e s – H u t c o u l d o p t i m i z e , b u t O ( n ² ) i s f i n e f o r < 5 0 0 n o d e s )
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
/ / R e p u l s i o n : O p t i m i z e d u s i n g 1 D s p a t i a l s o r t i n g ( X - a x i s ) t o r e d u c e O ( n ² ) t o O ( n l o g n )
/ / T h i s a l l o w s s m o o t h r e n d e r i n g e v e n f o r > 1 0 0 0 n o d e s
const sortedNodes = [ ... graphNodes ] . sort ( ( a , b ) => a . x - b . x )
const MAX _REPULSION _DIST = 300 / / O n l y c a l c u l a t e r e p u l s i o n f o r n o d e s w i t h i n 3 0 0 p x
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
/ / B e c a u s e n o d e s a r e s o r t e d b y X , i f d x > M A X _ R E P U L S I O N _ D I S T ,
/ / a l l s u b s e q u e n t n 2 n o d e s w i l l a l s o b e t o o f a r o n t h e X a x i s , s o w e c a n b r e a k e a r l y
if ( dx > MAX _REPULSION _DIST ) break
const dy = n2 . y - n1 . y
if ( Math . abs ( dy ) > MAX _REPULSION _DIST ) continue / / T o o f a r o n Y a x i s
const distSq = dx * dx + dy * dy
if ( distSq > MAX _REPULSION _DIST _SQ ) continue
const dist = Math . sqrt ( distSq ) || 1
/ / P r e v e n t e x t r e m e l y h i g h r e p u l s i o n w h e n n o d e s a r e v e r y c l o s e
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 ( ) {
/ / H i d e l a b e l s w h e n z o o m e d o u t t o o m u c h o r h i d e l e s s i m p o r t a n t l a b e l s
/ / W e o n l y w a n t t o s h o w l a b e l s f o r i m p o r t a n t n o d e s ( h i g h l i n k c o u n t ) w h e n z o o m e d o u t
for ( const { text , node } of graphNodeElsRef ) {
if ( node . slug === graphSelectedSlug . value || node . slug === graphHighlightSlug . value ) {
text . style . opacity = '1' / / A l w a y s s h o w s e l e c t e d / h i g h l i g h t e d
continue
}
let visibilityThreshold = 0.5 / / D e f a u l t : n e e d t o z o o m i n t o a t l e a s t 0 . 5 t o s e e a l l l a b e l s
/ / H i g h l y c o n n e c t e d n o d e s g e t t h e i r l a b e l s s h o w n e a r l i e r
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'
}
}
}
/ / E x p o r t m e t h o d s f o r p r o g r a m m a t i c p a n / z o o m
@@ -1164,21 +1338,25 @@ function applyHighlight(
const neighbors = adjacency . get ( slug ) || new Set ( )
const hoverNeighbors = hoverSlug ? ( adjacency . get ( hoverSlug ) || new Set ( ) ) : new Set ( )
/ / H e l p e r t o g e t c o n s i s t e n t r a d i u s
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) {
/ / F i n d n o d e c o o r d i n a t e s
const node = graphNodes . find ( n => n . slug === value )
/ / I f t h e n o d e ' s t y p e i s c u r r e n t l y f i l t e r e d o u t , r e - e n a b l e i t s o i t b e c o m e s v i s i b l e
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 : 8 px ;
font - size : 11 px ;
color : var ( -- td - text - color - secondary ) ;
& . clickable {
cursor : pointer ;
transition : all 0.15 s ;
& : 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 - 12 px ;
}
. legend - actions {
display : flex ;
flex - direction : column ;
gap : 8 px ;
}
. legend - action {
display : flex ;
align - items : center ;