feat: 添加 3D 图谱配置对话框,增强节点标签显示功能;优化配置界面和交互体验

This commit is contained in:
xiongxiao
2026-03-16 00:19:24 +08:00
committed by cnb
parent 070d8d8cd1
commit 31a3c48c48
2 changed files with 157 additions and 51 deletions

View File

@@ -2,11 +2,15 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import ForceGraph3D from '3d-force-graph'; import ForceGraph3D from '3d-force-graph';
import type { NodeObject, LinkObject, ForceGraph3DInstance } from '3d-force-graph'; import type { NodeObject, LinkObject, ForceGraph3DInstance } from '3d-force-graph';
import * as THREE from 'three'; import * as THREE from 'three';
import SpriteText from 'three-spritetext';
import { SlidersHorizontalIcon } from 'lucide-react';
import { FileProjectData } from '../modules/tree'; import { FileProjectData } from '../modules/tree';
import { NodeSearchEntry } from '../modules/graph'; import { NodeSearchEntry } from '../modules/graph';
import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox'; import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox';
import { useCodeGraphStore } from '../store'; import { useCodeGraphStore } from '../store';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useGraph3DConfig } from '../modules/graph3d-config';
import { Graph3DConfigDialog } from './Graph3DConfigDialog';
// ─── 类型定义 ───────────────────────────────────────────────────────────────── // ─── 类型定义 ─────────────────────────────────────────────────────────────────
@@ -190,6 +194,13 @@ export function Code3DGraph({ files, className, type }: Code3DGraphProps) {
const graphRef = useRef<ForceGraph3DInstance | null>(null); const graphRef = useRef<ForceGraph3DInstance | null>(null);
const searchBoxRef = useRef<NodeSearchBoxHandle>(null); const searchBoxRef = useRef<NodeSearchBoxHandle>(null);
const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]); const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]);
const [configOpen, setConfigOpen] = useState(false);
const { config, updateConfig, resetConfig } = useGraph3DConfig();
const configRef = useRef(config);
useEffect(() => {
configRef.current = config;
}, [config]);
const codeGraphStore = useCodeGraphStore( const codeGraphStore = useCodeGraphStore(
useShallow((s) => ({ useShallow((s) => ({
@@ -262,8 +273,13 @@ export function Code3DGraph({ files, className, type }: Code3DGraphProps) {
.nodeLabel((node) => (node as Graph3DNode).label) .nodeLabel((node) => (node as Graph3DNode).label)
.nodeThreeObject(((node: Graph3DNode) => { .nodeThreeObject(((node: Graph3DNode) => {
const n = node as Graph3DNode; const n = node as Graph3DNode;
if (!n?.fullPath || n.fullPath !== selectedNodeIdRef.current) return null; const isSelected = n?.fullPath && n.fullPath === selectedNodeIdRef.current;
const showLabels = configRef.current.showLabels;
const group = new THREE.Group();
// 选中节点:发光球效果
if (isSelected) {
const geometry = new THREE.SphereGeometry(n.nodeSize * 1.2, 32, 32); const geometry = new THREE.SphereGeometry(n.nodeSize * 1.2, 32, 32);
const material = new THREE.MeshStandardMaterial({ const material = new THREE.MeshStandardMaterial({
color: n.color, color: n.color,
@@ -274,20 +290,31 @@ export function Code3DGraph({ files, className, type }: Code3DGraphProps) {
transparent: true, transparent: true,
opacity: 0.9, opacity: 0.9,
}); });
const mesh = new THREE.Mesh(geometry, material); const mesh = new THREE.Mesh(geometry, material);
const glowGeometry = new THREE.SphereGeometry(n.nodeSize * 2, 32, 32); const glowGeometry = new THREE.SphereGeometry(n.nodeSize * 2, 32, 32);
const glowMaterial = new THREE.MeshBasicMaterial({ const glowMaterial = new THREE.MeshBasicMaterial({
color: n.color, color: n.color,
transparent: true, transparent: true,
opacity: 0.25, opacity: 0.25,
}); });
const glowMesh = new THREE.Mesh(glowGeometry, glowMaterial); mesh.add(new THREE.Mesh(glowGeometry, glowMaterial));
mesh.add(glowMesh); group.add(mesh);
}
return mesh; // 文字标签SpriteText
if (showLabels) {
const sprite = new SpriteText(n.label);
(sprite.material as THREE.SpriteMaterial).depthWrite = false;
sprite.color = n.color;
sprite.textHeight = n.kind === 'root' ? 4 : n.kind === 'dir' ? 2.5 : 2;
sprite.center.set(0.5, -0.4);
group.add(sprite);
}
if (group.children.length === 0) return null;
return group;
}) as any) }) as any)
.nodeThreeObjectExtend(((node: Graph3DNode) => configRef.current.showLabels) as any)
.linkWidth(0) .linkWidth(0)
.linkColor(() => 'rgba(255,255,255,0.6)') .linkColor(() => 'rgba(255,255,255,0.6)')
.linkDirectionalParticles(2) .linkDirectionalParticles(2)
@@ -364,9 +391,35 @@ export function Code3DGraph({ files, className, type }: Code3DGraphProps) {
} }
}, [codeGraphStore.selectedNodeId]); }, [codeGraphStore.selectedNodeId]);
// 配置变化时更新 nodeThreeObjectExtend 并刷新
useEffect(() => {
const graph = graphRef.current;
if (!graph) return;
(graph as any).nodeThreeObjectExtend(((node: Graph3DNode) => configRef.current.showLabels) as any);
graph.refresh();
}, [config]);
return ( return (
<div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}> <div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
<div ref={containerRef} className='w-full h-full' /> <div ref={containerRef} className='w-full h-full' />
{/* 设置按钮 */}
<button
onClick={() => setConfigOpen(true)}
className='absolute top-3 right-3 z-10 flex items-center justify-center w-8 h-8 rounded-md bg-slate-800/80 hover:bg-slate-700/90 border border-white/10 text-slate-300 hover:text-white transition-colors backdrop-blur'
title='3D 图谱配置'>
<SlidersHorizontalIcon className='w-4 h-4' />
</button>
{/* 配置弹窗 */}
<Graph3DConfigDialog
open={configOpen}
onOpenChange={setConfigOpen}
config={config}
onUpdate={updateConfig}
onReset={resetConfig}
/>
<div className='absolute top-3 left-1/2 -translate-x-1/2 z-10 w-72'> <div className='absolute top-3 left-1/2 -translate-x-1/2 z-10 w-72'>
<NodeSearchBox <NodeSearchBox
ref={searchBoxRef} ref={searchBoxRef}

View File

@@ -1,5 +1,5 @@
import { SlidersHorizontalIcon } from 'lucide-react'; import { SlidersHorizontalIcon, TagIcon, SparklesIcon, RotateCcwIcon, CheckIcon } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Graph3DConfig } from '../modules/graph3d-config'; import { Graph3DConfig } from '../modules/graph3d-config';
@@ -12,50 +12,103 @@ interface Graph3DConfigDialogProps {
onReset: () => void; onReset: () => void;
} }
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className='flex items-center gap-2 mb-3'>
<div className='h-px flex-1 bg-gradient-to-r from-white/10 to-transparent' />
<span className='text-[10px] font-semibold tracking-widest text-slate-500 uppercase'>{children}</span>
<div className='h-px flex-1 bg-gradient-to-l from-white/10 to-transparent' />
</div>
);
}
interface ConfigRowProps {
icon: React.ReactNode;
title: string;
description: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
}
function ConfigRow({ icon, title, description, checked, onCheckedChange }: ConfigRowProps) {
return (
<label className='group flex items-center gap-4 rounded-lg border border-white/5 bg-white/[0.03] hover:bg-white/[0.06] hover:border-white/10 px-4 py-3 cursor-pointer transition-all duration-150 select-none'>
<div className='flex-shrink-0 w-8 h-8 rounded-md bg-slate-800 border border-white/10 flex items-center justify-center text-slate-400 group-hover:text-slate-200 transition-colors'>
{icon}
</div>
<div className='flex-1 min-w-0'>
<p className='text-sm font-medium text-slate-200 leading-snug'>{title}</p>
<p className='text-xs text-slate-500 mt-0.5 leading-snug'>{description}</p>
</div>
<Checkbox
checked={checked}
onCheckedChange={(v) => onCheckedChange(!!v)}
className='flex-shrink-0'
/>
</label>
);
}
export function Graph3DConfigDialog({ open, onOpenChange, config, onUpdate, onReset }: Graph3DConfigDialogProps) { export function Graph3DConfigDialog({ open, onOpenChange, config, onUpdate, onReset }: Graph3DConfigDialogProps) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-md'> <DialogContent className='max-w-sm p-0 overflow-hidden border-white/10 bg-slate-900'>
{/* 标题栏 */}
<div className='relative px-5 pt-5 pb-4 border-b border-white/5'>
{/* 顶部光晕装饰 */}
<div className='pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-indigo-500/40 to-transparent' />
<DialogHeader> <DialogHeader>
<DialogTitle className='flex items-center gap-2'> <DialogTitle className='flex items-center gap-2.5 text-slate-100'>
<SlidersHorizontalIcon className='w-4 h-4' /> <div className='w-7 h-7 rounded-md bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center'>
3D <SlidersHorizontalIcon className='w-3.5 h-3.5 text-indigo-400' />
</div>
<span className='text-sm font-semibold'>3D </span>
</DialogTitle> </DialogTitle>
<DialogDescription> 3D </DialogDescription>
</DialogHeader> </DialogHeader>
<p className='mt-1.5 text-xs text-slate-500 pl-9'> 3D </p>
</div>
<div className='py-2 space-y-5'> {/* 配置内容 */}
<div className='px-5 py-4 space-y-5'>
{/* ── 显示 ── */} {/* ── 显示 ── */}
<section> <section>
<p className='text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3'></p> <SectionTitle></SectionTitle>
<div className='space-y-3'> <div className='space-y-2'>
<label className='flex items-center gap-3 cursor-pointer select-none group'> <ConfigRow
<Checkbox icon={<TagIcon className='w-3.5 h-3.5' />}
title='节点文字标签'
description='在节点旁以 3D 精灵字体显示文件名'
checked={config.showLabels} checked={config.showLabels}
onCheckedChange={(checked) => onUpdate({ showLabels: !!checked })} onCheckedChange={(v) => onUpdate({ showLabels: v })}
/> />
<div>
<p className='text-sm font-medium text-slate-100'></p>
<p className='text-xs text-slate-400'> 3D </p>
</div>
</label>
</div> </div>
</section> </section>
{/* ── 其他配置暂留 ── */} {/* ── 其他配置暂留 ── */}
<section> <section>
<p className='text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3'></p> <SectionTitle></SectionTitle>
<div className='rounded-md border border-dashed border-white/10 px-4 py-3 text-xs text-slate-500'> <div className='flex items-center gap-3 rounded-lg border border-dashed border-white/8 px-4 py-3'>
<SparklesIcon className='w-3.5 h-3.5 text-slate-600 flex-shrink-0' />
<span className='text-xs text-slate-600'></span>
</div> </div>
</section> </section>
</div> </div>
<div className='flex justify-between pt-2'> {/* 底部操作栏 */}
<Button variant='ghost' size='sm' className='text-slate-400 hover:text-slate-200' onClick={onReset}> <div className='flex items-center justify-between px-5 py-3 border-t border-white/5 bg-slate-950/40'>
<button
onClick={onReset}
className='flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors'
>
<RotateCcwIcon className='w-3 h-3' />
</Button> </button>
<Button size='sm' onClick={() => onOpenChange(false)}> <Button
size='sm'
className='h-7 px-4 text-xs bg-indigo-600 hover:bg-indigo-500 border-0 gap-1.5'
onClick={() => onOpenChange(false)}
>
<CheckIcon className='w-3 h-3' />
</Button> </Button>
</div> </div>