) => {
+ if (disposed) return
+ g.updateNodeData([{ id, style }])
+ void Promise.resolve(g.draw())
+ }
+
+ const readRenderedNodeSize = (id: string) => {
+ const modelSize = Number(g.getNodeData(id)?.style?.size)
+ if (Number.isFinite(modelSize) && modelSize > 0) return modelSize
+
+ const renderedSize = Number(g.getElementRenderStyle(id)?.size)
+ if (Number.isFinite(renderedSize) && renderedSize > 0) return renderedSize
+
+ return null
+ }
+
+ const restoreNodeWithBounce = (
+ id: string,
+ node: GraphViewNodeData,
+ restSize: number,
+ labelText: string,
+ onComplete: () => void,
+ ) => {
+ cancelNodeHoverAnimation(id)
+
+ const restingStyle = {
+ labelText,
+ size: restSize,
+ shadowColor: nebulaModeRef.current ? (id === selectedNodeIdRef.current ? nodeColor(node) : 'transparent') : 'transparent',
+ shadowBlur: nebulaModeRef.current ? (id === selectedNodeIdRef.current ? 10 : 0) : 0,
+ }
+
+ if (prefersReducedMotion()) {
+ drawNodePatch(id, restingStyle)
+ onComplete()
+ return
+ }
+
+ const startTime = window.performance.now()
+ const peakSize = restSize * NODE_HOVER_BOUNCE_RATIO
+ const startSize = graphNodeHoverSize(restSize)
+
+ const step = (now: number) => {
+ if (disposed) return
+
+ const elapsed = now - startTime
+ const progress = Math.min(1, elapsed / NODE_HOVER_RETURN_MS)
+ const stretchProgress = Math.min(1, progress / NODE_HOVER_STRETCH_PHASE)
+ const settleProgress = progress <= NODE_HOVER_STRETCH_PHASE
+ ? 0
+ : (progress - NODE_HOVER_STRETCH_PHASE) / (1 - NODE_HOVER_STRETCH_PHASE)
+ const size = progress <= NODE_HOVER_STRETCH_PHASE
+ ? interpolateNumber(startSize, peakSize, easeOutCubic(stretchProgress))
+ : interpolateNumber(peakSize, restSize, easeOutCubic(settleProgress))
+
+ drawNodePatch(id, {
+ ...restingStyle,
+ size,
+ })
+
+ if (progress < 1) {
+ hoverAnimationFrames.set(id, window.requestAnimationFrame(step))
+ return
+ }
+
+ hoverAnimationFrames.delete(id)
+ drawNodePatch(id, restingStyle)
+ onComplete()
+ }
+
+ hoverAnimationFrames.set(id, window.requestAnimationFrame(step))
+ }
+
const syncEdgeLabelVisibility = () => {
if (disposed || !graphReady || filteredEdges.length === 0) return
+ const zoom = readGraphZoom()
+ const selectedEdgeId = selectedEdgeIdRef.current
+ const currentNebulaMode = nebulaModeRef.current
const visibleIds = shouldShowEdgeLabels(
layoutSettings.edgeLabelMode,
- g.getZoom(),
+ zoom,
filteredEdges,
selectedEdgeId,
)
- const visibleKey = `${layoutSettings.edgeLabelMode}:${g.getZoom().toFixed(2)}:${selectedEdgeId || ''}:${visibleIds.size}`
+ const visibleKey = [
+ layoutSettings.edgeLabelMode,
+ zoom.toFixed(2),
+ selectedEdgeId || '',
+ currentNebulaMode ? 'nebula' : 'readable',
+ ...visibleIds,
+ ].join(':')
if (edgeLabelsVisibleKey === visibleKey) return
edgeLabelsVisibleKey = visibleKey
g.updateEdgeData(filteredEdges.map(edge => ({
id: edge.id,
- style: { badgeText: visibleIds.has(edge.id) ? formatEdgeLabel(edge) : '' },
+ style: graphEdgeVisualStyle(
+ edge,
+ layoutSettings,
+ selectedEdgeId,
+ currentNebulaMode,
+ visibleIds.has(edge.id),
+ ),
+ })))
+ void Promise.resolve(g.draw())
+ }
+
+ const syncGraphVisualState = () => {
+ if (disposed || !graphReady) return
+ const zoom = readGraphZoom()
+ const selectedNodeId = selectedNodeIdRef.current
+ const selectedEdgeId = selectedEdgeIdRef.current
+ const currentCenterSkillId = centerSkillIdRef.current
+ const currentNebulaMode = nebulaModeRef.current
+ const visibleIds = shouldShowEdgeLabels(
+ layoutSettings.edgeLabelMode,
+ zoom,
+ filteredEdges,
+ selectedEdgeId,
+ )
+ edgeLabelsVisibleKey = [
+ layoutSettings.edgeLabelMode,
+ zoom.toFixed(2),
+ selectedEdgeId || '',
+ currentNebulaMode ? 'nebula' : 'readable',
+ ...visibleIds,
+ ].join(':')
+
+ g.updateNodeData(graphData.nodes.map(node => ({
+ id: node.id,
+ style: graphNodeVisualStyle(
+ node,
+ layoutSettings,
+ selectedNodeId,
+ currentCenterSkillId,
+ currentNebulaMode,
+ ),
+ })))
+ g.updateEdgeData(filteredEdges.map(edge => ({
+ id: edge.id,
+ style: graphEdgeVisualStyle(
+ edge,
+ layoutSettings,
+ selectedEdgeId,
+ currentNebulaMode,
+ visibleIds.has(edge.id),
+ ),
})))
void Promise.resolve(g.draw())
}
- const updateHoveredNodeLabel = (id: string | undefined, visible: boolean) => {
- if (!id || layoutSettings.labelMode !== 'hover') return
- const node = graphData.nodes.find(item => item.id === id)
+ const updateHoveredNodeState = (id: string | undefined, visible: boolean) => {
+ const activeId = id || hoveredNodeId || undefined
+ if (!activeId) return
+
+ if (!visible && hoveredNodeId !== activeId) return
+
+ const previousHoveredId = hoveredNodeId
+ if (visible && previousHoveredId && previousHoveredId !== activeId) {
+ updateHoveredNodeState(previousHoveredId, false)
+ }
+
+ if (visible) hoveredNodeId = activeId
+ if (!visible && hoveredNodeId === activeId) hoveredNodeId = null
+
+ const node = graphData.nodes.find(item => item.id === activeId)
if (!node) return
- const selected = id === selectedNodeId
- const centered = id === centerSkillId
- const shouldKeep = shouldShowNodeLabel(layoutSettings.labelMode, {
+
+ const selected = activeId === selectedNodeIdRef.current
+ const centered = activeId === centerSkillIdRef.current
+ const currentNebulaMode = nebulaModeRef.current
+ const labelShouldPersist = shouldShowNodeLabel(layoutSettings.labelMode, {
selected,
centered,
denseMode: layoutSettings.denseMode,
})
- g.updateNodeData([{
- id,
- style: {
- labelText: visible || shouldKeep ? node.name : '',
- size: visible ? Math.max(nodeVisualSize(node, layoutSettings, selected, centered) + 5, 18) : nodeVisualSize(node, layoutSettings, selected, centered),
- },
- }])
- void Promise.resolve(g.draw())
+ const labelText = layoutSettings.labelMode === 'hover'
+ ? (visible || labelShouldPersist ? node.name : '')
+ : (labelShouldPersist ? node.name : '')
+ const fallbackRestSize = graphNodeRestSize(node, layoutSettings, selected, centered, currentNebulaMode)
+
+ if (visible) {
+ cancelNodeHoverAnimation(activeId)
+ const restSize = hoverRestSizes.get(activeId) ?? readRenderedNodeSize(activeId) ?? fallbackRestSize
+ hoverRestSizes.set(activeId, restSize)
+ drawNodePatch(activeId, {
+ labelText,
+ size: graphNodeHoverSize(restSize),
+ shadowColor: currentNebulaMode ? nodeColor(node) : 'rgba(22, 119, 255, 0.28)',
+ shadowBlur: currentNebulaMode ? 12 : 8,
+ })
+ return
+ }
+
+ const restSize = hoverRestSizes.get(activeId) ?? fallbackRestSize
+ restoreNodeWithBounce(activeId, node, restSize, labelText, () => {
+ hoverRestSizes.delete(activeId)
+ })
}
g.on('node:click', event => {
const id = event.target?.id
if (id) {
+ selectedNodeIdRef.current = id
+ selectedEdgeIdRef.current = null
+ syncGraphVisualState()
setSelectedNodeId(id)
setSelectedEdgeId(null)
}
@@ -893,15 +1158,25 @@ export default function SkillGraph() {
g.on('edge:click', event => {
const id = event.target?.id
if (id) {
+ selectedEdgeIdRef.current = id
+ selectedNodeIdRef.current = null
+ syncGraphVisualState()
setSelectedEdgeId(id)
setSelectedNodeId(null)
}
})
- g.on('node:pointerenter', event => updateHoveredNodeLabel(event.target?.id, true))
- g.on('node:pointerleave', event => updateHoveredNodeLabel(event.target?.id, false))
+ g.on('node:pointerenter', event => updateHoveredNodeState(event.target?.id, true))
+ g.on('node:pointerleave', event => updateHoveredNodeState(event.target?.id, false))
+ g.on('node:pointerout', event => updateHoveredNodeState(event.target?.id, false))
+ g.on('edge:pointerenter', () => updateHoveredNodeState(undefined, false))
+ g.on('canvas:pointermove', () => updateHoveredNodeState(undefined, false))
+ g.on('canvas:pointerleave', () => updateHoveredNodeState(undefined, false))
g.on('canvas:click', () => {
+ selectedNodeIdRef.current = null
+ selectedEdgeIdRef.current = null
+ syncGraphVisualState()
setSelectedNodeId(null)
setSelectedEdgeId(null)
})
@@ -909,19 +1184,25 @@ export default function SkillGraph() {
// Zoom → nebula mode transition
g.on('aftertransform', () => {
if (disposed) return
- const zoom = g.getZoom()
- setNebulaMode(zoom < NEBULA_ZOOM_THRESHOLD)
+ const zoom = readGraphZoom()
+ const nextNebulaMode = zoom < NEBULA_ZOOM_THRESHOLD
+ if (nebulaModeRef.current !== nextNebulaMode) {
+ nebulaModeRef.current = nextNebulaMode
+ setNebulaMode(nextNebulaMode)
+ syncGraphVisualState()
+ return
+ }
syncEdgeLabelVisibility()
})
graphRef.current = g
+ syncGraphVisualStateRef.current = syncGraphVisualState
void Promise.resolve(g.render()).then(() => {
if (!disposed) {
graphReady = true
syncEdgeLabelVisibility()
void Promise.resolve(g.fitView({ when: 'always' }, { duration: 160 })).then(() => {
syncEdgeLabelVisibility()
- g.on('aftertransform', syncEdgeLabelVisibility)
})
}
})
@@ -929,12 +1210,16 @@ export default function SkillGraph() {
return () => {
disposed = true
+ syncGraphVisualStateRef.current = null
+ hoverAnimationFrames.forEach(frameId => window.cancelAnimationFrame(frameId))
+ hoverAnimationFrames.clear()
+ hoverRestSizes.clear()
if (graphRef.current) {
graphRef.current.destroy()
graphRef.current = null
}
}
- }, [canvasSize, centerSkillId, edgeFilter, graphData, layoutSettings, nebulaMode, selectedEdgeId, selectedNodeId])
+ }, [canvasSize, edgeFilter, graphData, layoutSettings])
const handleViewChange = (nextView: GraphViewMode) => {
setViewMode(nextView)
@@ -1177,7 +1462,7 @@ export default function SkillGraph() {
>
{loading && !graphData ? (
-
+
) : !hasNodes ? (
@@ -1375,6 +1660,7 @@ export default function SkillGraph() {
{graphData && (
{graphPresetName(layoutSettings)} preset
+ {relationStrengthText && {relationStrengthText}}
{graphData.nodes.length} nodes / {graphData.edges.length} edges
)}
diff --git a/skillwiki-frontend/src/pages/SkillWiki.tsx b/skillwiki-frontend/src/pages/SkillWiki.tsx
index e1a0ca8..cb89169 100644
--- a/skillwiki-frontend/src/pages/SkillWiki.tsx
+++ b/skillwiki-frontend/src/pages/SkillWiki.tsx
@@ -416,7 +416,8 @@ export default function SkillWiki() {
loading={loading}
size="middle"
pagination={{ pageSize: 15, showSizeChanger: true }}
- style={{ borderRadius: 8, overflow: 'hidden' }}
+ className="skillwiki-table-contained"
+ scroll={{ x: 980 }}
/>
diff --git a/skillwiki-frontend/src/pages/VersionControl.tsx b/skillwiki-frontend/src/pages/VersionControl.tsx
index ff43655..1862d7c 100644
--- a/skillwiki-frontend/src/pages/VersionControl.tsx
+++ b/skillwiki-frontend/src/pages/VersionControl.tsx
@@ -745,6 +745,8 @@ export default function VersionControl() {
loading={loadingVersions}
size="small"
pagination={false}
+ className="skillwiki-table-contained"
+ scroll={{ x: 760 }}
locale={{ emptyText: selectedId ? 'No version records' : 'Select a Skill first' }}
/>
@@ -916,6 +918,8 @@ export default function VersionControl() {
loading={historyLoading}
size="small"
pagination={{ pageSize: 5 }}
+ className="skillwiki-table-contained"
+ scroll={{ x: 980 }}
locale={{ emptyText: 'No snapshot commits yet' }}
/>