| import React, { useEffect, useRef, useState } from 'react'; |
| import { InspirationItem } from '../types'; |
|
|
| interface Node { |
| id: string; |
| label: string; |
| type: 'inspiration' | 'tag'; |
| x: number; |
| y: number; |
| vx: number; |
| vy: number; |
| radius: number; |
| color: string; |
| data?: InspirationItem; |
| } |
|
|
| interface Link { |
| source: string; |
| target: string; |
| strength: number; |
| } |
|
|
| interface InspirationGraphProps { |
| inspirations: InspirationItem[]; |
| onNodeClick: (inspiration: InspirationItem | null) => void; |
| } |
|
|
| |
| const TAG_COLORS: Record<string, string> = { |
| '随想': '#C7B3FF', |
| '自然': '#9FE2BF', |
| '设计': '#FFB3D9', |
| '创意': '#FFD4A3', |
| '生活': '#A3D5FF', |
| '提醒': '#FFE699', |
| '工作': '#C4B5FD', |
| '学习': '#B8C5FF', |
| '友情': '#FFB8C8', |
| '成长': '#C8E6A0', |
| }; |
|
|
| |
| const calculateSimilarity = (item1: InspirationItem, item2: InspirationItem): number => { |
| const tags1 = new Set(item1.tags); |
| const tags2 = new Set(item2.tags); |
| const intersection = new Set([...tags1].filter(x => tags2.has(x))); |
| const union = new Set([...tags1, ...tags2]); |
| return union.size > 0 ? intersection.size / union.size : 0; |
| }; |
|
|
| export const InspirationGraph: React.FC<InspirationGraphProps> = ({ |
| inspirations, |
| onNodeClick |
| }) => { |
| const canvasRef = useRef<HTMLCanvasElement>(null); |
| const [nodes, setNodes] = useState<Node[]>([]); |
| const [links, setLinks] = useState<Link[]>([]); |
| const [selectedNode, setSelectedNode] = useState<string | null>(null); |
| const animationRef = useRef<number>(); |
| const [hoveredNode, setHoveredNode] = useState<string | null>(null); |
|
|
| |
| useEffect(() => { |
| if (!canvasRef.current || inspirations.length === 0) return; |
|
|
| const canvas = canvasRef.current; |
| const width = canvas.width; |
| const height = canvas.height; |
|
|
| |
| const allTags = new Set<string>(); |
| inspirations.forEach(item => { |
| item.tags.forEach(tag => allTags.add(tag)); |
| }); |
|
|
| const tagArray = Array.from(allTags); |
| const newNodes: Node[] = []; |
| const newLinks: Link[] = []; |
|
|
| |
| tagArray.forEach((tag: string, index: number) => { |
| const angle = (index / tagArray.length) * Math.PI * 2; |
| const radius = Math.min(width, height) * 0.15; |
| newNodes.push({ |
| id: `tag-${tag}`, |
| label: tag, |
| type: 'tag', |
| x: width / 2 + Math.cos(angle) * radius, |
| y: height / 2 + Math.sin(angle) * radius, |
| vx: 0, |
| vy: 0, |
| radius: 8, |
| color: TAG_COLORS[tag] || '#E2E8F0', |
| }); |
| }); |
|
|
| |
| inspirations.forEach((item: InspirationItem, index: number) => { |
| const angle = (index / inspirations.length) * Math.PI * 2 + Math.random() * 0.3; |
| const radius = Math.min(width, height) * 0.35 + Math.random() * 50; |
| newNodes.push({ |
| id: item.id, |
| label: item.content.substring(0, 8) + '...', |
| type: 'inspiration', |
| x: width / 2 + Math.cos(angle) * radius, |
| y: height / 2 + Math.sin(angle) * radius, |
| vx: (Math.random() - 0.5) * 2, |
| vy: (Math.random() - 0.5) * 2, |
| radius: 6, |
| color: '#A78BFA', |
| data: item, |
| }); |
|
|
| |
| item.tags.forEach((tag: string) => { |
| newLinks.push({ |
| source: item.id, |
| target: `tag-${tag}`, |
| strength: 0.8, |
| }); |
| }); |
| }); |
|
|
| |
| for (let i = 0; i < inspirations.length; i++) { |
| for (let j = i + 1; j < inspirations.length; j++) { |
| const similarity = calculateSimilarity(inspirations[i], inspirations[j]); |
| if (similarity > 0.4) { |
| newLinks.push({ |
| source: inspirations[i].id, |
| target: inspirations[j].id, |
| strength: similarity * 0.5, |
| }); |
| } |
| } |
| } |
|
|
| setNodes(newNodes); |
| setLinks(newLinks); |
| }, [inspirations]); |
|
|
| |
| useEffect(() => { |
| if (!canvasRef.current || nodes.length === 0) return; |
|
|
| const canvas = canvasRef.current; |
| const ctx = canvas.getContext('2d'); |
| if (!ctx) return; |
|
|
| const width = canvas.width; |
| const height = canvas.height; |
|
|
| let isRunning = true; |
|
|
| const simulate = () => { |
| if (!isRunning) return; |
|
|
| |
| ctx.clearRect(0, 0, width, height); |
|
|
| |
| const alpha = 0.15; |
| const centerForce = 0.003; |
| const repelForce = 1500; |
| const linkForce = 0.04; |
|
|
| |
| nodes.forEach((node: Node) => { |
| if (node.type === 'tag') { |
| const dx = width / 2 - node.x; |
| const dy = height / 2 - node.y; |
| node.vx += dx * centerForce; |
| node.vy += dy * centerForce; |
| } |
| }); |
|
|
| |
| for (let i = 0; i < nodes.length; i++) { |
| for (let j = i + 1; j < nodes.length; j++) { |
| const dx = nodes[j].x - nodes[i].x; |
| const dy = nodes[j].y - nodes[i].y; |
| const distance = Math.sqrt(dx * dx + dy * dy) || 1; |
| const minDistance = nodes[i].radius + nodes[j].radius + 30; |
| |
| if (distance < minDistance) { |
| const force = repelForce / (distance * distance) * 2; |
| const fx = (dx / distance) * force; |
| const fy = (dy / distance) * force; |
| nodes[i].vx -= fx; |
| nodes[i].vy -= fy; |
| nodes[j].vx += fx; |
| nodes[j].vy += fy; |
| } else { |
| const force = repelForce / (distance * distance); |
| const fx = (dx / distance) * force; |
| const fy = (dy / distance) * force; |
| nodes[i].vx -= fx; |
| nodes[i].vy -= fy; |
| nodes[j].vx += fx; |
| nodes[j].vy += fy; |
| } |
| } |
| } |
|
|
| |
| links.forEach((link: Link) => { |
| const source = nodes.find((n: Node) => n.id === link.source); |
| const target = nodes.find((n: Node) => n.id === link.target); |
| if (source && target) { |
| const dx = target.x - source.x; |
| const dy = target.y - source.y; |
| const distance = Math.sqrt(dx * dx + dy * dy) || 1; |
| const idealDistance = 150; |
| const force = (distance - idealDistance) * linkForce * link.strength; |
| const fx = (dx / distance) * force; |
| const fy = (dy / distance) * force; |
| source.vx += fx; |
| source.vy += fy; |
| target.vx -= fx; |
| target.vy -= fy; |
| } |
| }); |
|
|
| |
| nodes.forEach((node: Node) => { |
| node.x += node.vx * alpha; |
| node.y += node.vy * alpha; |
| node.vx *= 0.88; |
| node.vy *= 0.88; |
|
|
| const margin = node.radius + 20; |
| node.x = Math.max(margin, Math.min(width - margin, node.x)); |
| node.y = Math.max(margin, Math.min(height - margin, node.y)); |
| }); |
|
|
| |
| links.forEach((link: Link) => { |
| const source = nodes.find((n: Node) => n.id === link.source); |
| const target = nodes.find((n: Node) => n.id === link.target); |
| if (source && target) { |
| ctx.beginPath(); |
| ctx.moveTo(source.x, source.y); |
| ctx.lineTo(target.x, target.y); |
| |
| |
| if (source.type === 'inspiration' && target.type === 'inspiration') { |
| |
| ctx.setLineDash([3, 6]); |
| ctx.strokeStyle = 'rgba(167, 139, 250, 0.08)'; |
| ctx.lineWidth = 0.8; |
| } else { |
| |
| ctx.setLineDash([]); |
| ctx.strokeStyle = 'rgba(167, 139, 250, 0.12)'; |
| ctx.lineWidth = 1; |
| } |
| |
| ctx.globalAlpha = link.strength * 0.5; |
| ctx.stroke(); |
| ctx.globalAlpha = 1; |
| ctx.setLineDash([]); |
| } |
| }); |
|
|
| |
| nodes.forEach((node: Node) => { |
| const isSelected = selectedNode === node.id; |
| const isHovered = hoveredNode === node.id; |
|
|
| |
| if (isSelected || isHovered) { |
| ctx.save(); |
| ctx.shadowColor = node.color; |
| ctx.shadowBlur = 20; |
| ctx.globalAlpha = 0.4; |
| ctx.fillStyle = node.color; |
| ctx.beginPath(); |
| ctx.arc(node.x, node.y, node.radius + 6, 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.restore(); |
| } |
|
|
| |
| ctx.fillStyle = node.color; |
| ctx.globalAlpha = isHovered || isSelected ? 1 : 0.85; |
| ctx.beginPath(); |
| ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.globalAlpha = 1; |
|
|
| |
| if (isHovered || isSelected) { |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| } |
| }); |
|
|
| animationRef.current = requestAnimationFrame(simulate); |
| }; |
|
|
| simulate(); |
|
|
| return () => { |
| isRunning = false; |
| if (animationRef.current) { |
| cancelAnimationFrame(animationRef.current); |
| } |
| }; |
| }, [nodes, links, selectedNode, hoveredNode]); |
|
|
| |
| const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => { |
| const canvas = canvasRef.current; |
| if (!canvas) return; |
|
|
| const rect = canvas.getBoundingClientRect(); |
| const x = e.clientX - rect.left; |
| const y = e.clientY - rect.top; |
|
|
| |
| const clickedNode = nodes.find((node: Node) => { |
| const dx = x - node.x; |
| const dy = y - node.y; |
| return Math.sqrt(dx * dx + dy * dy) <= node.radius + 10; |
| }); |
|
|
| if (clickedNode) { |
| if (clickedNode.type === 'inspiration' && clickedNode.data) { |
| setSelectedNode(clickedNode.id); |
| onNodeClick(clickedNode.data); |
| } else if (clickedNode.type === 'tag') { |
| setSelectedNode(clickedNode.id); |
| onNodeClick(null); |
| } |
| } else { |
| setSelectedNode(null); |
| onNodeClick(null); |
| } |
| }; |
|
|
| |
| const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => { |
| const canvas = canvasRef.current; |
| if (!canvas) return; |
|
|
| const rect = canvas.getBoundingClientRect(); |
| const x = e.clientX - rect.left; |
| const y = e.clientY - rect.top; |
|
|
| const hoveredNode = nodes.find((node: Node) => { |
| const dx = x - node.x; |
| const dy = y - node.y; |
| return Math.sqrt(dx * dx + dy * dy) <= node.radius + 10; |
| }); |
|
|
| setHoveredNode(hoveredNode ? hoveredNode.id : null); |
| canvas.style.cursor = hoveredNode ? 'pointer' : 'default'; |
| }; |
|
|
| return ( |
| <div className="relative w-full h-full bg-gradient-to-br from-white/80 via-purple-50/40 to-pink-50/40"> |
| <canvas |
| ref={canvasRef} |
| width={800} |
| height={600} |
| className="w-full h-full" |
| onClick={handleClick} |
| onMouseMove={handleMouseMove} |
| style={{ touchAction: 'none' }} |
| /> |
| |
| {/* 悬停提示 - 苹果风格毛玻璃效果 */} |
| {hoveredNode && ( |
| <div |
| className="absolute pointer-events-none transition-all duration-200 ease-out" |
| style={{ |
| left: `${(nodes.find((n: Node) => n.id === hoveredNode)?.x || 0) / 800 * 100}%`, |
| top: `${(nodes.find((n: Node) => n.id === hoveredNode)?.y || 0) / 600 * 100 + 3}%`, |
| transform: 'translateX(-50%)', |
| }} |
| > |
| <div className="bg-white/95 backdrop-blur-2xl rounded-2xl px-4 py-2.5 shadow-xl border border-white/60 animate-[fadeIn_0.15s_ease-out]"> |
| <p className="text-xs font-medium text-slate-700 whitespace-nowrap max-w-[220px] truncate"> |
| {nodes.find((n: Node) => n.id === hoveredNode)?.type === 'tag' |
| ? nodes.find((n: Node) => n.id === hoveredNode)?.label |
| : nodes.find((n: Node) => n.id === hoveredNode)?.data?.content.substring(0, 35) + '...' |
| } |
| </p> |
| </div> |
| </div> |
| )} |
| |
| {/* 极简图例 - 苹果风格 */} |
| <div className="absolute bottom-6 left-1/2 -translate-x-1/2 bg-white/80 backdrop-blur-2xl rounded-full px-5 py-2 shadow-lg border border-white/60"> |
| <div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium"> |
| <div className="flex items-center gap-1.5"> |
| <div className="w-1.5 h-1.5 rounded-full bg-purple-400"></div> |
| <span>灵感</span> |
| </div> |
| <div className="w-px h-3 bg-slate-300"></div> |
| <div className="flex items-center gap-1.5"> |
| <div className="w-1.5 h-1.5 rounded-full bg-slate-400"></div> |
| <span>标签</span> |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|