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 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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user