feat: 添加 3D 图谱配置对话框,增强节点标签显示功能;优化配置界面和交互体验
This commit is contained in:
@@ -2,11 +2,15 @@ import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import ForceGraph3D from '3d-force-graph';
|
||||
import type { NodeObject, LinkObject, ForceGraph3DInstance } from '3d-force-graph';
|
||||
import * as THREE from 'three';
|
||||
import SpriteText from 'three-spritetext';
|
||||
import { SlidersHorizontalIcon } from 'lucide-react';
|
||||
import { FileProjectData } from '../modules/tree';
|
||||
import { NodeSearchEntry } from '../modules/graph';
|
||||
import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox';
|
||||
import { useCodeGraphStore } from '../store';
|
||||
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 searchBoxRef = useRef<NodeSearchBoxHandle>(null);
|
||||
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(
|
||||
useShallow((s) => ({
|
||||
@@ -262,32 +273,48 @@ export function Code3DGraph({ files, className, type }: Code3DGraphProps) {
|
||||
.nodeLabel((node) => (node as Graph3DNode).label)
|
||||
.nodeThreeObject(((node: 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 geometry = new THREE.SphereGeometry(n.nodeSize * 1.2, 32, 32);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: n.color,
|
||||
emissive: n.color,
|
||||
emissiveIntensity: 1.5,
|
||||
roughness: 0.3,
|
||||
metalness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
});
|
||||
const group = new THREE.Group();
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
// 选中节点:发光球效果
|
||||
if (isSelected) {
|
||||
const geometry = new THREE.SphereGeometry(n.nodeSize * 1.2, 32, 32);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: n.color,
|
||||
emissive: n.color,
|
||||
emissiveIntensity: 1.5,
|
||||
roughness: 0.3,
|
||||
metalness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
const glowGeometry = new THREE.SphereGeometry(n.nodeSize * 2, 32, 32);
|
||||
const glowMaterial = new THREE.MeshBasicMaterial({
|
||||
color: n.color,
|
||||
transparent: true,
|
||||
opacity: 0.25,
|
||||
});
|
||||
mesh.add(new THREE.Mesh(glowGeometry, glowMaterial));
|
||||
group.add(mesh);
|
||||
}
|
||||
|
||||
const glowGeometry = new THREE.SphereGeometry(n.nodeSize * 2, 32, 32);
|
||||
const glowMaterial = new THREE.MeshBasicMaterial({
|
||||
color: n.color,
|
||||
transparent: true,
|
||||
opacity: 0.25,
|
||||
});
|
||||
const glowMesh = new THREE.Mesh(glowGeometry, glowMaterial);
|
||||
mesh.add(glowMesh);
|
||||
// 文字标签: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);
|
||||
}
|
||||
|
||||
return mesh;
|
||||
if (group.children.length === 0) return null;
|
||||
return group;
|
||||
}) as any)
|
||||
.nodeThreeObjectExtend(((node: Graph3DNode) => configRef.current.showLabels) as any)
|
||||
.linkWidth(0)
|
||||
.linkColor(() => 'rgba(255,255,255,0.6)')
|
||||
.linkDirectionalParticles(2)
|
||||
@@ -364,9 +391,35 @@ export function Code3DGraph({ files, className, type }: Code3DGraphProps) {
|
||||
}
|
||||
}, [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 (
|
||||
<div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
|
||||
<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'>
|
||||
<NodeSearchBox
|
||||
ref={searchBoxRef}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SlidersHorizontalIcon } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { SlidersHorizontalIcon, TagIcon, SparklesIcon, RotateCcwIcon, CheckIcon } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Graph3DConfig } from '../modules/graph3d-config';
|
||||
@@ -12,50 +12,103 @@ interface Graph3DConfigDialogProps {
|
||||
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) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<SlidersHorizontalIcon className='w-4 h-4' />
|
||||
3D 图谱配置
|
||||
</DialogTitle>
|
||||
<DialogDescription>配置 3D 力导向图的显示效果,设置自动保存到本地。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
<DialogTitle className='flex items-center gap-2.5 text-slate-100'>
|
||||
<div className='w-7 h-7 rounded-md bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center'>
|
||||
<SlidersHorizontalIcon className='w-3.5 h-3.5 text-indigo-400' />
|
||||
</div>
|
||||
<span className='text-sm font-semibold'>3D 图谱配置</span>
|
||||
</DialogTitle>
|
||||
</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>
|
||||
<p className='text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3'>显示</p>
|
||||
<div className='space-y-3'>
|
||||
<label className='flex items-center gap-3 cursor-pointer select-none group'>
|
||||
<Checkbox
|
||||
checked={config.showLabels}
|
||||
onCheckedChange={(checked) => onUpdate({ showLabels: !!checked })}
|
||||
/>
|
||||
<div>
|
||||
<p className='text-sm font-medium text-slate-100'>显示节点文字标签</p>
|
||||
<p className='text-xs text-slate-400'>在节点旁以 3D 精灵字体显示名称</p>
|
||||
</div>
|
||||
</label>
|
||||
<SectionTitle>显示</SectionTitle>
|
||||
<div className='space-y-2'>
|
||||
<ConfigRow
|
||||
icon={<TagIcon className='w-3.5 h-3.5' />}
|
||||
title='节点文字标签'
|
||||
description='在节点旁以 3D 精灵字体显示文件名'
|
||||
checked={config.showLabels}
|
||||
onCheckedChange={(v) => onUpdate({ showLabels: v })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 其他配置暂留 ── */}
|
||||
<section>
|
||||
<p className='text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3'>其他(暂留)</p>
|
||||
<div className='rounded-md border border-dashed border-white/10 px-4 py-3 text-xs text-slate-500'>
|
||||
更多配置项开发中…
|
||||
<SectionTitle>更多</SectionTitle>
|
||||
<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>
|
||||
</section>
|
||||
</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 size='sm' onClick={() => onOpenChange(false)}>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user