mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 03:42:38 +02:00
graph view
This commit is contained in:
parent
174dcaf3ee
commit
102a9e5855
4 changed files with 766 additions and 3 deletions
|
|
@ -164,3 +164,33 @@
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-view {
|
||||
background-color: var(--background);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.graph-view::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(var(--border) 0.6px, transparent 0.6px);
|
||||
background-size: 22px 22px;
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-view > svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.graph-view:active > svg {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.graph-view text {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import z from 'zod';
|
|||
import { Button } from './components/ui/button';
|
||||
import { MessageSquare, CheckIcon, LoaderIcon } from 'lucide-react';
|
||||
import { MarkdownEditor } from './components/markdown-editor';
|
||||
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
||||
import { useDebounce } from './hooks/use-debounce';
|
||||
import { SidebarIcon } from '@/components/sidebar-icon';
|
||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||
|
|
@ -115,6 +116,20 @@ const toToolState = (status: ToolCall['status']): ToolState => {
|
|||
}
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 256
|
||||
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
|
||||
const graphPalette = [
|
||||
{ hue: 210, sat: 72, light: 52 },
|
||||
{ hue: 28, sat: 78, light: 52 },
|
||||
{ hue: 120, sat: 62, light: 48 },
|
||||
{ hue: 170, sat: 66, light: 46 },
|
||||
{ hue: 280, sat: 70, light: 56 },
|
||||
{ hue: 330, sat: 68, light: 54 },
|
||||
{ hue: 55, sat: 80, light: 52 },
|
||||
{ hue: 0, sat: 72, light: 52 },
|
||||
]
|
||||
|
||||
const clampNumber = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value))
|
||||
|
||||
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||
if (!usage) return null
|
||||
|
|
@ -237,6 +252,13 @@ function App() {
|
|||
const [tree, setTree] = useState<TreeNode[]>([])
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
||||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
})
|
||||
const [graphStatus, setGraphStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
const [graphError, setGraphError] = useState<string | null>(null)
|
||||
|
||||
// Auto-save state
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
|
@ -544,6 +566,7 @@ function App() {
|
|||
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||
if (kind === 'file') {
|
||||
setSelectedPath(path)
|
||||
setIsGraphOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -560,6 +583,7 @@ function App() {
|
|||
const handleSectionChange = useCallback((section: ActiveSection) => {
|
||||
if (section === 'ask-ai' || section === 'agents') {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -568,6 +592,13 @@ function App() {
|
|||
const files = collectFilePaths(tree).filter((path) => path.endsWith('.md'))
|
||||
return Array.from(new Set(files.map(stripKnowledgePrefix)))
|
||||
}, [tree])
|
||||
const knowledgeFilePaths = React.useMemo(() => (
|
||||
knowledgeFiles.reduce<string[]>((acc, filePath) => {
|
||||
const resolved = toKnowledgePath(filePath)
|
||||
if (resolved) acc.push(resolved)
|
||||
return acc
|
||||
}, [])
|
||||
), [knowledgeFiles])
|
||||
|
||||
// Get workspace root for full paths
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
|
||||
|
|
@ -587,6 +618,7 @@ function App() {
|
|||
data: `# New Note\n\n`,
|
||||
opts: { encoding: 'utf8' }
|
||||
})
|
||||
setIsGraphOpen(false)
|
||||
setSelectedPath(fullPath)
|
||||
} catch (err) {
|
||||
console.error('Failed to create note:', err)
|
||||
|
|
@ -604,6 +636,10 @@ function App() {
|
|||
throw err
|
||||
}
|
||||
},
|
||||
openGraph: () => {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
},
|
||||
expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))),
|
||||
collapseAll: () => setExpandedPaths(new Set()),
|
||||
rename: async (oldPath: string, newName: string, isDir: boolean) => {
|
||||
|
|
@ -671,6 +707,119 @@ function App() {
|
|||
onCreate: (path: string) => ensureWikiFile(path),
|
||||
}), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGraphOpen) return
|
||||
let cancelled = false
|
||||
|
||||
const buildGraph = async () => {
|
||||
setGraphStatus('loading')
|
||||
setGraphError(null)
|
||||
|
||||
if (knowledgeFilePaths.length === 0) {
|
||||
setGraphData({ nodes: [], edges: [] })
|
||||
setGraphStatus('ready')
|
||||
return
|
||||
}
|
||||
|
||||
const nodeSet = new Set(knowledgeFilePaths)
|
||||
const edges: GraphEdge[] = []
|
||||
const edgeKeys = new Set<string>()
|
||||
|
||||
const contents = await Promise.all(
|
||||
knowledgeFilePaths.map(async (path) => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path })
|
||||
return { path, data: result.data as string }
|
||||
} catch (err) {
|
||||
console.error('Failed to read file for graph:', path, err)
|
||||
return { path, data: '' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
for (const { path, data } of contents) {
|
||||
for (const match of data.matchAll(wikiLinkRegex)) {
|
||||
const rawTarget = match[1]?.trim() ?? ''
|
||||
const targetPath = toKnowledgePath(rawTarget)
|
||||
if (!targetPath || targetPath === path) continue
|
||||
if (!nodeSet.has(targetPath)) continue
|
||||
const edgeKey = path < targetPath ? `${path}|${targetPath}` : `${targetPath}|${path}`
|
||||
if (edgeKeys.has(edgeKey)) continue
|
||||
edgeKeys.add(edgeKey)
|
||||
edges.push({ source: path, target: targetPath })
|
||||
}
|
||||
}
|
||||
|
||||
const degreeMap = new Map<string, number>()
|
||||
edges.forEach((edge) => {
|
||||
degreeMap.set(edge.source, (degreeMap.get(edge.source) ?? 0) + 1)
|
||||
degreeMap.set(edge.target, (degreeMap.get(edge.target) ?? 0) + 1)
|
||||
})
|
||||
|
||||
const groupIndexMap = new Map<string, number>()
|
||||
const getGroupIndex = (group: string) => {
|
||||
const existing = groupIndexMap.get(group)
|
||||
if (existing !== undefined) return existing
|
||||
const nextIndex = groupIndexMap.size
|
||||
groupIndexMap.set(group, nextIndex)
|
||||
return nextIndex
|
||||
}
|
||||
const getNodeGroup = (path: string) => {
|
||||
const normalized = stripKnowledgePrefix(path)
|
||||
const parts = normalized.split('/').filter(Boolean)
|
||||
if (parts.length <= 1) {
|
||||
return { group: 'root', depth: 0 }
|
||||
}
|
||||
return {
|
||||
group: parts[0],
|
||||
depth: Math.max(0, parts.length - 2),
|
||||
}
|
||||
}
|
||||
const getNodeColors = (groupIndex: number, depth: number) => {
|
||||
const base = graphPalette[groupIndex % graphPalette.length]
|
||||
const light = clampNumber(base.light + depth * 6, 36, 72)
|
||||
const strokeLight = clampNumber(light - 12, 28, 60)
|
||||
return {
|
||||
fill: `hsl(${base.hue} ${base.sat}% ${light}%)`,
|
||||
stroke: `hsl(${base.hue} ${Math.min(80, base.sat + 8)}% ${strokeLight}%)`,
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = knowledgeFilePaths.map((path) => {
|
||||
const degree = degreeMap.get(path) ?? 0
|
||||
const radius = 6 + Math.min(18, degree * 2)
|
||||
const { group, depth } = getNodeGroup(path)
|
||||
const groupIndex = getGroupIndex(group)
|
||||
const colors = getNodeColors(groupIndex, depth)
|
||||
return {
|
||||
id: path,
|
||||
label: wikiLabel(path) || path,
|
||||
degree,
|
||||
radius,
|
||||
group,
|
||||
color: colors.fill,
|
||||
stroke: colors.stroke,
|
||||
}
|
||||
})
|
||||
|
||||
if (!cancelled) {
|
||||
setGraphData({ nodes, edges })
|
||||
setGraphStatus('ready')
|
||||
}
|
||||
}
|
||||
|
||||
buildGraph().catch((err) => {
|
||||
if (cancelled) return
|
||||
console.error('Failed to build graph:', err)
|
||||
setGraphStatus('error')
|
||||
setGraphError(err instanceof Error ? err.message : 'Failed to build graph')
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isGraphOpen, knowledgeFilePaths])
|
||||
|
||||
const renderConversationItem = (item: ConversationItem) => {
|
||||
if (isChatMessage(item)) {
|
||||
return (
|
||||
|
|
@ -757,6 +906,7 @@ function App() {
|
|||
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
|
||||
const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready'
|
||||
const canSubmit = Boolean(message.trim()) && !isProcessing
|
||||
const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat')
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
|
@ -789,7 +939,7 @@ function App() {
|
|||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{selectedPath ? selectedPath : 'Chat'}
|
||||
{headerTitle}
|
||||
</span>
|
||||
{selectedPath && (
|
||||
<>
|
||||
|
|
@ -818,9 +968,32 @@ function App() {
|
|||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!selectedPath && isGraphOpen && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsGraphOpen(false)}
|
||||
className="ml-auto text-foreground"
|
||||
>
|
||||
Close Graph
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{selectedPath ? (
|
||||
{isGraphOpen ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<GraphView
|
||||
nodes={graphData.nodes}
|
||||
edges={graphData.edges}
|
||||
isLoading={graphStatus === 'loading'}
|
||||
error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null}
|
||||
onSelectNode={(path) => {
|
||||
setIsGraphOpen(false)
|
||||
setSelectedPath(path)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : selectedPath ? (
|
||||
selectedPath.endsWith('.md') ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<MarkdownEditor
|
||||
|
|
|
|||
559
apps/x/apps/renderer/src/components/graph-view.tsx
Normal file
559
apps/x/apps/renderer/src/components/graph-view.tsx
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export type GraphNode = {
|
||||
id: string
|
||||
label: string
|
||||
degree: number
|
||||
radius: number
|
||||
group: string
|
||||
color: string
|
||||
stroke: string
|
||||
}
|
||||
|
||||
export type GraphEdge = {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
type GraphViewProps = {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
isLoading?: boolean
|
||||
error?: string | null
|
||||
onSelectNode?: (id: string) => void
|
||||
}
|
||||
|
||||
type NodePosition = {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
}
|
||||
|
||||
const SIMULATION_STEPS = 240
|
||||
const SPRING_LENGTH = 80
|
||||
const SPRING_STRENGTH = 0.0038
|
||||
const REPULSION = 5800
|
||||
const DAMPING = 0.83
|
||||
const MIN_DISTANCE = 34
|
||||
const CLUSTER_STRENGTH = 0.0018
|
||||
const CLUSTER_RADIUS_MIN = 120
|
||||
const CLUSTER_RADIUS_MAX = 240
|
||||
const CLUSTER_RADIUS_STEP = 45
|
||||
const FLOAT_BASE = 3.5
|
||||
const FLOAT_VARIANCE = 2
|
||||
const FLOAT_SPEED_BASE = 0.0006
|
||||
const FLOAT_SPEED_VARIANCE = 0.00025
|
||||
|
||||
export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: GraphViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const positionsRef = useRef<Map<string, NodePosition>>(new Map())
|
||||
const motionSeedsRef = useRef<Map<string, { phase: number; amplitude: number; speed: number }>>(new Map())
|
||||
const motionTimeRef = useRef(0)
|
||||
const draggingRef = useRef<{
|
||||
id: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
moved: boolean
|
||||
} | null>(null)
|
||||
const panningRef = useRef<{
|
||||
startX: number
|
||||
startY: number
|
||||
originX: number
|
||||
originY: number
|
||||
} | null>(null)
|
||||
const hasCenteredRef = useRef(false)
|
||||
const [viewport, setViewport] = useState({ width: 1, height: 1 })
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null)
|
||||
const [, forceRender] = useState(0)
|
||||
|
||||
const edgeList = useMemo(
|
||||
() => edges.filter((edge) => edge.source !== edge.target),
|
||||
[edges]
|
||||
)
|
||||
const nodeGroupMap = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
nodes.forEach((node) => map.set(node.id, node.group || 'root'))
|
||||
return map
|
||||
}, [nodes])
|
||||
const legendItems = useMemo(() => {
|
||||
const grouped = new Map<string, { label: string; color: string; stroke: string }>()
|
||||
nodes.forEach((node) => {
|
||||
const group = node.group || 'root'
|
||||
if (grouped.has(group)) return
|
||||
grouped.set(group, {
|
||||
label: group === 'root' ? 'knowledge' : group,
|
||||
color: node.color,
|
||||
stroke: node.stroke,
|
||||
})
|
||||
})
|
||||
return Array.from(grouped.values()).sort((a, b) => a.label.localeCompare(b.label))
|
||||
}, [nodes])
|
||||
const groupCenters = useMemo(() => {
|
||||
const groups = Array.from(new Set(nodes.map((node) => node.group || 'root')))
|
||||
if (groups.length === 0) return new Map<string, { x: number; y: number }>()
|
||||
const radius = Math.min(
|
||||
CLUSTER_RADIUS_MAX,
|
||||
Math.max(CLUSTER_RADIUS_MIN, groups.length * CLUSTER_RADIUS_STEP)
|
||||
)
|
||||
const centers = new Map<string, { x: number; y: number }>()
|
||||
groups.forEach((group, index) => {
|
||||
const angle = (index / groups.length) * Math.PI * 2
|
||||
centers.set(group, {
|
||||
x: radius * Math.cos(angle),
|
||||
y: radius * Math.sin(angle),
|
||||
})
|
||||
})
|
||||
return centers
|
||||
}, [nodes])
|
||||
|
||||
const getMotionSeed = useCallback((id: string) => {
|
||||
const existing = motionSeedsRef.current.get(id)
|
||||
if (existing) return existing
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i += 1) {
|
||||
hash = (hash << 5) - hash + id.charCodeAt(i)
|
||||
hash |= 0
|
||||
}
|
||||
const normalized = Math.abs(hash)
|
||||
const phase = ((normalized % 360) * Math.PI) / 180
|
||||
const amplitude = FLOAT_BASE + (normalized % 7) * (FLOAT_VARIANCE / 6)
|
||||
const speed = FLOAT_SPEED_BASE + (normalized % 5) * FLOAT_SPEED_VARIANCE
|
||||
const seed = { phase, amplitude, speed }
|
||||
motionSeedsRef.current.set(id, seed)
|
||||
return seed
|
||||
}, [])
|
||||
|
||||
const getDisplayPosition = useCallback((id: string, base: NodePosition, skipMotion: boolean) => {
|
||||
if (skipMotion) {
|
||||
return { x: base.x, y: base.y }
|
||||
}
|
||||
const seed = getMotionSeed(id)
|
||||
const phase = seed.phase + motionTimeRef.current * seed.speed
|
||||
return {
|
||||
x: base.x + Math.sin(phase) * seed.amplitude,
|
||||
y: base.y + Math.cos(phase * 0.9) * seed.amplitude,
|
||||
}
|
||||
}, [getMotionSeed])
|
||||
|
||||
const getGraphPoint = useCallback((event: React.PointerEvent) => {
|
||||
const container = containerRef.current
|
||||
if (!container) return { x: 0, y: 0 }
|
||||
const rect = container.getBoundingClientRect()
|
||||
return {
|
||||
x: (event.clientX - rect.left - pan.x) / zoom,
|
||||
y: (event.clientY - rect.top - pan.y) / zoom,
|
||||
}
|
||||
}, [pan.x, pan.y, zoom])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
const { width, height } = entry.contentRect
|
||||
setViewport({ width, height })
|
||||
if (!hasCenteredRef.current) {
|
||||
setPan({ x: width / 2, y: height / 2 })
|
||||
hasCenteredRef.current = true
|
||||
}
|
||||
})
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) {
|
||||
positionsRef.current = new Map()
|
||||
return
|
||||
}
|
||||
|
||||
const nextPositions = new Map<string, NodePosition>()
|
||||
const count = nodes.length
|
||||
const radius = Math.max(110, Math.min(220, count * 9))
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
const existing = positionsRef.current.get(node.id)
|
||||
if (existing) {
|
||||
nextPositions.set(node.id, { ...existing })
|
||||
return
|
||||
}
|
||||
const angle = (index / count) * Math.PI * 2
|
||||
nextPositions.set(node.id, {
|
||||
x: radius * Math.cos(angle),
|
||||
y: radius * Math.sin(angle),
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
})
|
||||
})
|
||||
|
||||
positionsRef.current = nextPositions
|
||||
|
||||
let step = 0
|
||||
let rafId = 0
|
||||
let active = true
|
||||
|
||||
const simulate = () => {
|
||||
if (!active) return
|
||||
step += 1
|
||||
|
||||
const positions = positionsRef.current
|
||||
const ids = nodes.map((node) => node.id)
|
||||
const forces = new Map<string, { x: number; y: number }>()
|
||||
|
||||
ids.forEach((id) => forces.set(id, { x: 0, y: 0 }))
|
||||
|
||||
for (let i = 0; i < ids.length; i += 1) {
|
||||
const idA = ids[i]
|
||||
const posA = positions.get(idA)
|
||||
if (!posA) continue
|
||||
for (let j = i + 1; j < ids.length; j += 1) {
|
||||
const idB = ids[j]
|
||||
const posB = positions.get(idB)
|
||||
if (!posB) continue
|
||||
const dx = posB.x - posA.x
|
||||
const dy = posB.y - posA.y
|
||||
const distance = Math.max(MIN_DISTANCE, Math.hypot(dx, dy))
|
||||
const force = REPULSION / (distance * distance)
|
||||
const fx = (force * dx) / distance
|
||||
const fy = (force * dy) / distance
|
||||
const forceA = forces.get(idA)
|
||||
const forceB = forces.get(idB)
|
||||
if (forceA) {
|
||||
forceA.x -= fx
|
||||
forceA.y -= fy
|
||||
}
|
||||
if (forceB) {
|
||||
forceB.x += fx
|
||||
forceB.y += fy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
edgeList.forEach((edge) => {
|
||||
const posA = positions.get(edge.source)
|
||||
const posB = positions.get(edge.target)
|
||||
if (!posA || !posB) return
|
||||
const dx = posB.x - posA.x
|
||||
const dy = posB.y - posA.y
|
||||
const distance = Math.max(20, Math.hypot(dx, dy))
|
||||
const delta = distance - SPRING_LENGTH
|
||||
const force = delta * SPRING_STRENGTH
|
||||
const fx = (force * dx) / distance
|
||||
const fy = (force * dy) / distance
|
||||
const forceA = forces.get(edge.source)
|
||||
const forceB = forces.get(edge.target)
|
||||
if (forceA) {
|
||||
forceA.x += fx
|
||||
forceA.y += fy
|
||||
}
|
||||
if (forceB) {
|
||||
forceB.x -= fx
|
||||
forceB.y -= fy
|
||||
}
|
||||
})
|
||||
|
||||
ids.forEach((id) => {
|
||||
const pos = positions.get(id)
|
||||
const force = forces.get(id)
|
||||
if (!pos || !force) return
|
||||
const group = nodeGroupMap.get(id) ?? 'root'
|
||||
const center = groupCenters.get(group)
|
||||
if (!center) return
|
||||
const dx = center.x - pos.x
|
||||
const dy = center.y - pos.y
|
||||
force.x += dx * CLUSTER_STRENGTH
|
||||
force.y += dy * CLUSTER_STRENGTH
|
||||
})
|
||||
|
||||
ids.forEach((id) => {
|
||||
const pos = positions.get(id)
|
||||
const force = forces.get(id)
|
||||
if (!pos || !force) return
|
||||
if (draggingRef.current?.id === id) {
|
||||
pos.vx = 0
|
||||
pos.vy = 0
|
||||
return
|
||||
}
|
||||
pos.vx = (pos.vx + force.x) * DAMPING
|
||||
pos.vy = (pos.vy + force.y) * DAMPING
|
||||
pos.x += pos.vx
|
||||
pos.y += pos.vy
|
||||
})
|
||||
|
||||
forceRender((prev) => prev + 1)
|
||||
|
||||
if (step < SIMULATION_STEPS) {
|
||||
rafId = requestAnimationFrame(simulate)
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(simulate)
|
||||
return () => {
|
||||
active = false
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
}
|
||||
}, [nodes, edgeList, groupCenters, nodeGroupMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) return
|
||||
let rafId = 0
|
||||
let lastTime = performance.now()
|
||||
|
||||
const animate = (time: number) => {
|
||||
const delta = time - lastTime
|
||||
if (delta >= 32) {
|
||||
motionTimeRef.current += delta
|
||||
lastTime = time
|
||||
forceRender((prev) => prev + 1)
|
||||
}
|
||||
rafId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(animate)
|
||||
return () => {
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
}
|
||||
}, [nodes.length])
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
panningRef.current = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: pan.x,
|
||||
originY: pan.y,
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent) => {
|
||||
const dragging = draggingRef.current
|
||||
if (dragging) {
|
||||
const point = getGraphPoint(event)
|
||||
const pos = positionsRef.current.get(dragging.id)
|
||||
if (pos) {
|
||||
pos.x = point.x - dragging.offsetX
|
||||
pos.y = point.y - dragging.offsetY
|
||||
dragging.moved = true
|
||||
forceRender((prev) => prev + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const panning = panningRef.current
|
||||
if (panning) {
|
||||
setPan({
|
||||
x: panning.originX + (event.clientX - panning.startX),
|
||||
y: panning.originY + (event.clientY - panning.startY),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
const dragging = draggingRef.current
|
||||
if (dragging) {
|
||||
if (!dragging.moved) {
|
||||
onSelectNode?.(dragging.id)
|
||||
}
|
||||
draggingRef.current = null
|
||||
}
|
||||
panningRef.current = null
|
||||
}
|
||||
|
||||
const handleWheel = (event: React.WheelEvent) => {
|
||||
event.preventDefault()
|
||||
const rawDelta = event.deltaY
|
||||
const normalizedDelta = event.deltaMode === 1
|
||||
? rawDelta * 16
|
||||
: event.deltaMode === 2
|
||||
? rawDelta * viewport.height
|
||||
: rawDelta
|
||||
const sensitivity = Math.abs(normalizedDelta) < 40 ? 0.004 : 0.0022
|
||||
const zoomFactor = Math.exp(-normalizedDelta * sensitivity)
|
||||
const nextZoom = Math.min(2.5, Math.max(0.4, zoom * zoomFactor))
|
||||
if (nextZoom === zoom) return
|
||||
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
setZoom(nextZoom)
|
||||
return
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
const cursorX = event.clientX - rect.left
|
||||
const cursorY = event.clientY - rect.top
|
||||
const graphX = (cursorX - pan.x) / zoom
|
||||
const graphY = (cursorY - pan.y) / zoom
|
||||
setZoom(nextZoom)
|
||||
setPan({
|
||||
x: cursorX - graphX * nextZoom,
|
||||
y: cursorY - graphY * nextZoom,
|
||||
})
|
||||
}
|
||||
|
||||
const startDragNode = (event: React.PointerEvent, nodeId: string) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
const point = getGraphPoint(event)
|
||||
const pos = positionsRef.current.get(nodeId)
|
||||
if (!pos) return
|
||||
const displayPos = getDisplayPosition(nodeId, pos, false)
|
||||
draggingRef.current = {
|
||||
id: nodeId,
|
||||
offsetX: point.x - displayPos.x,
|
||||
offsetY: point.y - displayPos.y,
|
||||
moved: false,
|
||||
}
|
||||
}
|
||||
|
||||
const displayPositions = new Map<string, { x: number; y: number }>()
|
||||
nodes.forEach((node) => {
|
||||
const pos = positionsRef.current.get(node.id)
|
||||
if (!pos) return
|
||||
const isDragging = draggingRef.current?.id === node.id
|
||||
displayPositions.set(node.id, getDisplayPosition(node.id, pos, isDragging))
|
||||
})
|
||||
const activeNodeId = hoveredNodeId ?? draggingRef.current?.id ?? null
|
||||
const connectedNodes = useMemo(() => {
|
||||
if (!activeNodeId) return null
|
||||
const set = new Set([activeNodeId])
|
||||
edgeList.forEach((edge) => {
|
||||
if (edge.source === activeNodeId) set.add(edge.target)
|
||||
if (edge.target === activeNodeId) set.add(edge.source)
|
||||
})
|
||||
return set
|
||||
}, [activeNodeId, edgeList])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="graph-view relative h-full w-full">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/70 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Building graph…</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && nodes.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
No notes found.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{legendItems.length > 0 ? (
|
||||
<div
|
||||
className="absolute right-3 top-3 z-20 rounded-md border border-border/80 bg-background/90 px-3 py-2 text-xs text-foreground shadow-sm backdrop-blur"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-2 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Folders
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{legendItems.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-flex h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: item.color, boxShadow: `0 0 0 1px ${item.stroke}` }}
|
||||
/>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<svg
|
||||
className="h-full w-full touch-none"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={() => {
|
||||
handlePointerUp()
|
||||
setHoveredNodeId(null)
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<rect width={viewport.width} height={viewport.height} fill="transparent" />
|
||||
<g transform={`translate(${pan.x} ${pan.y}) scale(${zoom})`}>
|
||||
{edgeList.map((edge, index) => {
|
||||
const source = displayPositions.get(edge.source)
|
||||
const target = displayPositions.get(edge.target)
|
||||
if (!source || !target) return null
|
||||
const isActiveEdge = activeNodeId
|
||||
? edge.source === activeNodeId || edge.target === activeNodeId
|
||||
: false
|
||||
const strokeOpacity = activeNodeId ? (isActiveEdge ? 0.9 : 0.08) : 0.38
|
||||
const strokeWidth = activeNodeId ? (isActiveEdge ? 1.6 : 0.7) : 1
|
||||
const stroke = 'var(--foreground)'
|
||||
return (
|
||||
<line
|
||||
key={`${edge.source}-${edge.target}-${index}`}
|
||||
x1={source.x}
|
||||
y1={source.y}
|
||||
x2={target.x}
|
||||
y2={target.y}
|
||||
stroke={stroke}
|
||||
strokeOpacity={strokeOpacity}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{nodes.map((node) => {
|
||||
const pos = displayPositions.get(node.id)
|
||||
if (!pos) return null
|
||||
const isConnected = connectedNodes ? connectedNodes.has(node.id) : true
|
||||
const isPrimary = activeNodeId === node.id
|
||||
const nodeOpacity = activeNodeId ? (isConnected ? 1 : 0.3) : 1
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${pos.x} ${pos.y})`}
|
||||
className="cursor-pointer"
|
||||
onPointerEnter={() => setHoveredNodeId(node.id)}
|
||||
onPointerLeave={() => setHoveredNodeId(null)}
|
||||
onPointerDown={(event) => startDragNode(event, node.id)}
|
||||
opacity={nodeOpacity}
|
||||
>
|
||||
<circle
|
||||
r={node.radius}
|
||||
fill={node.color}
|
||||
stroke={node.stroke}
|
||||
strokeWidth={isPrimary ? 1.8 : 1.2}
|
||||
/>
|
||||
<text
|
||||
x={node.radius + 6}
|
||||
y={4}
|
||||
className="text-xs"
|
||||
style={{
|
||||
fill: node.stroke,
|
||||
paintOrder: 'stroke',
|
||||
stroke: 'var(--background)',
|
||||
strokeWidth: 3,
|
||||
}}
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -75,6 +75,7 @@ interface TreeNode {
|
|||
type KnowledgeActions = {
|
||||
createNote: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => void
|
||||
openGraph: () => void
|
||||
expandAll: () => void
|
||||
collapseAll: () => void
|
||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||
|
|
@ -256,7 +257,7 @@ function KnowledgeSection({
|
|||
const quickActions = [
|
||||
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
||||
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
|
||||
{ icon: Network, label: "Graph View", action: () => {} },
|
||||
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
|
||||
{ icon: ArrowDownAZ, label: "Sort", action: () => {} },
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue