import { useRef, useEffect, useState, useCallback } from 'react'; import { characterNetwork } from '../data/character-network'; import { CharacterNode, Relationship } from '../types/character-network'; import { Box, Typography, Paper, Grid, IconButton, Tooltip, Chip, Divider, Container, CircularProgress, Fade } from '@mui/material'; import { ForceGraph2D as ForceGraph } from 'react-force-graph'; import { ForceGraph2D as ForceGraph } from 'react-force-graph'; import { ArrowBack, Help, ZoomIn, ZoomOut, CenterFocusStrong } from '@mui/icons-material'; import * as d3 from 'd3'; // Base node interface extending SimulationNodeDatum interface BaseNetworkNode extends d3.SimulationNodeDatum { id: string; name: string; type: 'protagonist' | 'antagonist' | 'supporting' | 'book' | 'character'; description?: string; novel?: string; class?: string; val: number; color: string; x?: number; y?: number; fx?: number; fy?: number; } // Book-specific node interface interface NetworkBookNode extends BaseNetworkNode { type: 'book'; year: string; } // Character-specific node interface interface NetworkCharacterNode extends BaseNetworkNode { type: 'protagonist' | 'antagonist' | 'supporting' | 'character'; } // Union type for all possible node types type NetworkNode = NetworkBookNode | NetworkCharacterNode; interface NetworkLink extends d3.SimulationLinkDatum { source: string | NetworkNode; target: string | NetworkNode; type: string; color: string; } // Particle type for emitParticle interface GraphParticle { id: string; source: NetworkNode; target: NetworkNode; } // Proper typing for ForceGraph methods interface ForceGraphMethods = NetworkLink> { // Complete ForceGraph methods interface interface ForceGraphMethods { zoom: (k?: number) => number; zoomToFit: (duration?: number, padding?: number) => void; d3Force: (forceName: string, force?: d3.Force) => void; d3ReheatSimulation: () => void; getZoom: () => number; emitParticle: (particle: GraphParticle) => void; pauseAnimation: () => void; resumeAnimation: () => void; centerAt: (x?: number, y?: number, duration?: number) => void; getGraphBbox: () => { x: [number, number]; y: [number, number] }; screen2GraphCoords: (x: number, y: number) => { x: number; y: number }; graph2ScreenCoords: (x: number, y: number) => { x: number; y: number }; width: () => number; height: () => number; refresh: () => void; } export default function NetworkVisualization() { const [selectedNode, setSelectedNode] = useState(null); const [selectedRelationships, setSelectedRelationships] = useState([]); const [selectedBook, setSelectedBook] = useState(null); const containerRef = useRef(null); const fgRef = useRef>(); const [isLoading, setIsLoading] = useState(true); const [isGraphReady, setIsGraphReady] = useState(false); const [dimensions, setDimensions] = useState({ width: 800, height: 700 }); // Add loading effect when data changes useEffect(() => { setIsLoading(true); const timer = setTimeout(() => { setIsLoading(false); }, 1000); return () => clearTimeout(timer); }, [selectedBook]); // Track when graph is ready useEffect(() => { if (fgRef.current) { setIsGraphReady(true); } }, [fgRef.current]); // Add a useEffect to handle container resizing useEffect(() => { const updateDimensions = () => { if (containerRef.current) { const { width, height } = containerRef.current.getBoundingClientRect(); setDimensions({ width, height }); } }; // Initial update updateDimensions(); // Add resize listener window.addEventListener('resize', updateDimensions); return () => window.removeEventListener('resize', updateDimensions); }, []); // Update the node interaction handlers const handleNodeClick = useCallback((node: NetworkNode) => { if (node.type === 'book') { const bookNode: NetworkBookNode = { ...node, year: (node as NetworkBookNode).year }; setSelectedBook(node.id); setSelectedNode(bookNode); setSelectedRelationships([]); } else { const characterNode: NetworkCharacterNode = { ...node, type: node.type as 'protagonist' | 'antagonist' | 'supporting' | 'character' }; const relations = characterNetwork.relationships.filter( (rel) => rel.source === node.id || rel.target === node.id ); setSelectedNode(characterNode); setSelectedRelationships(relations); } }, []); const handleBackClick = () => { setSelectedBook(null); setSelectedNode(null); setSelectedRelationships([]); }; // Update getGraphData with improved layout const getGraphData = useCallback((): { nodes: NetworkNode[]; links: NetworkLink[] } => { if (selectedBook) { const bookName = characterNetwork.books.find(b => b.id === selectedBook)?.name; const nodes = characterNetwork.nodes .filter(node => node.novel === bookName) .map((node, idx, arr) => { // Create a circular layout with more spacing const angle = (idx / arr.length) * 2 * Math.PI; // Increase radius for better spacing const radius = Math.min(dimensions.width, dimensions.height) * 0.35; const x = dimensions.width / 2 + radius * Math.cos(angle); const y = dimensions.height / 2 + radius * Math.sin(angle); return { ...node, val: 10, x, y, fx: x, fy: y, color: node.type === 'protagonist' ? sageColors.primary.start : node.type === 'antagonist' ? sageColors.secondary.start : sageColors.tertiary.start } as NetworkNode; }); // Improve link routing with curved paths const links = characterNetwork.relationships .filter(rel => nodes.some(n => n.id === rel.source) && nodes.some(n => n.id === rel.target)) .map(rel => ({ ...rel, color: sageColors.primary.end, curvature: 0.3 // Add consistent curve to all links })); return { nodes, links }; } else { // Create pentagon layout for books const centerX = dimensions.width / 2; const centerY = dimensions.height / 2; // Adjust radius based on container size const radius = Math.min(dimensions.width, dimensions.height) * 0.25; const nodes = characterNetwork.books.map((book, idx) => { const point = getPentagonPoint(idx, characterNetwork.books.length, radius, centerX, centerY); return { id: book.id, name: book.name, type: 'book' as const, description: book.description, val: 15, color: sageColors.primary.start, x: point.x, y: point.y, fx: point.x, fy: point.y } as NetworkNode; }); return { nodes, links: [] }; } }, [selectedBook, dimensions]); const renderNodeCanvas = useCallback((node: NetworkNode, ctx: CanvasRenderingContext2D, scale: number) => { const size = node.val || 5; const fontSize = Math.max(12 / scale, 2); // Save the current context state ctx.save(); // 3D effect with gradient and shadow const gradient = ctx.createRadialGradient( (node.x || 0) - size/3, (node.y || 0) - size/3, 0, node.x || 0, node.y || 0, size ); const baseColor = node.color || sageColors.primary.start; gradient.addColorStop(0, lightenColor(baseColor, 30)); gradient.addColorStop(0.8, baseColor); gradient.addColorStop(1, darkenColor(baseColor, 20)); // Add shadow for depth ctx.shadowColor = 'rgba(0, 0, 0, 0.2)'; ctx.shadowBlur = 5; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 2; // Draw node with 3D effect ctx.beginPath(); ctx.arc(node.x || 0, node.y || 0, size, 0, 2 * Math.PI); ctx.fillStyle = gradient; ctx.fill(); // Add highlight ring ctx.strokeStyle = lightenColor(baseColor, 40); ctx.lineWidth = 1; ctx.stroke(); // Clear shadow effect for text ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; // Only draw labels when zoomed in enough or for book nodes if (scale > 0.7 || node.type === 'book') { const labelDistance = size + fontSize; ctx.font = `${fontSize}px "Cormorant", serif`; const textWidth = ctx.measureText(node.name).width; // Semi-transparent background for better readability ctx.fillStyle = 'rgba(255, 255, 255, 0.95)'; const padding = fontSize * 0.3; const backgroundHeight = fontSize + padding * 2; // Draw background with rounded corners const cornerRadius = 4; const backgroundY = (node.y || 0) + labelDistance - fontSize / 2 - padding; roundRect( ctx, (node.x || 0) - textWidth / 2 - padding, backgroundY, textWidth + padding * 2, backgroundHeight, cornerRadius ); // Draw text ctx.fillStyle = node.id === selectedNode?.id ? sageColors.primary.start : '#000'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText( node.name, node.x || 0, (node.y || 0) + labelDistance ); } // Restore the context state ctx.restore(); }, [selectedNode]); // Helper function for drawing rounded rectangles const roundRect = ( ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number ) => { ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + width - radius, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radius); ctx.lineTo(x + width, y + height - radius); ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); ctx.lineTo(x + radius, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); ctx.fill(); }; // Add color utility functions const lightenColor = (color: string, percent: number): string => { const num = parseInt(color.replace('#', ''), 16); const amt = Math.round(2.55 * percent); const R = Math.min(255, ((num >> 16) & 0xff) + amt); const G = Math.min(255, ((num >> 8) & 0xff) + amt); const B = Math.min(255, (num & 0xff) + amt); return `#${(1 << 24 | R << 16 | G << 8 | B).toString(16).slice(1)}`; }; const darkenColor = (color: string, percent: number): string => { const num = parseInt(color.replace('#', ''), 16); const amt = Math.round(2.55 * percent); const R = Math.max(0, ((num >> 16) & 0xff) - amt); const G = Math.max(0, ((num >> 8) & 0xff) - amt); const B = Math.max(0, (num & 0xff) - amt); return `#${(1 << 24 | R << 16 | G << 8 | B).toString(16).slice(1)}`; }; const getLegendTooltip = () => ( Color Legend: Nodes:
• Protagonists - {sageColors.primary.start}
• Antagonists - {sageColors.secondary.start}
• Supporting - {sageColors.tertiary.start}
Relationships:
• Family - {relationshipColors.family}
• Romance - {relationshipColors.romance}
• Friendship - {relationshipColors.friendship}
• Rivalry - {relationshipColors.rivalry}
); const handleZoomIn = () => { if (fgRef.current) { const currentZoom = fgRef.current.zoom(); fgRef.current.zoom(currentZoom * 1.2); } }; const handleZoomOut = () => { if (fgRef.current) { const currentZoom = fgRef.current.zoom(); fgRef.current.zoom(currentZoom / 1.2); } }; const handleCenterGraph = () => { if (fgRef.current) { fgRef.current.zoomToFit(400); } }; useEffect(() => { if (fgRef.current) { // Clear existing forces fgRef.current.d3Force('charge', undefined); fgRef.current.d3Force('center', undefined); fgRef.current.d3Force('link', undefined); // Add stable forces with minimal movement fgRef.current.d3Force('charge', d3.forceManyBody().strength(-100)); fgRef.current.d3Force('center', d3.forceCenter(dimensions.width / 2, dimensions.height / 2).strength(0.05)); fgRef.current.d3Force('link', d3.forceLink().distance(80).strength(0.2)); // Reduce simulation intensity fgRef.current.d3Force('x', d3.forceX(dimensions.width / 2).strength(0.05)); fgRef.current.d3Force('y', d3.forceY(dimensions.height / 2).strength(0.05)); } }, [dimensions, selectedBook]); // Add useEffect to center the graph on mount and dimension changes useEffect(() => { if (fgRef.current) { // Center the graph with animation requestAnimationFrame(() => { fgRef.current?.zoomToFit(400, 100); // Increased padding for better centering }); } }, [dimensions, selectedBook]); const fgRef = useRef(); // ... rest of the component code ... return ( Character Network Double click the nodes to access the information inside them! {/* Loading overlay */} {selectedBook ? 'Loading character relationships...' : 'Loading books...'} {/* Graph container */} link.color} linkWidth={2} nodeRelSize={6} width={dimensions.width} height={dimensions.height} cooldownTicks={50} cooldownTime={3000} d3AlphaDecay={0.02} d3VelocityDecay={0.6} minZoom={0.5} maxZoom={4} enableNodeDrag={true} onNodeDragEnd={(node: NetworkNode) => { if (node.x && node.y) { node.fx = node.x; node.fy = node.y; } }} warmupTicks={0} nodeLabel={() => ''} linkCurvature={0.3} linkDirectionalParticles={0} onEngineStop={() => { // Ensure graph is centered after layout stabilizes fgRef.current?.zoomToFit(400, 100); }} /> {/* Controls */} {!isLoading && ( <> {selectedBook && ( )} handleZoomIn()} size="small" sx={{ color: sageColors.primary.start }} > handleZoomOut()} size="small" sx={{ color: sageColors.primary.start }} > handleCenterGraph()} size="small" sx={{ color: sageColors.primary.start }} > {!selectedBook ? 'Click a book to explore its characters' : 'Click characters to view relationships'} )} {selectedNode ? ( {selectedNode.name} {selectedNode.type !== 'book' && ( )} {selectedNode.type === 'book' ? `Published: ${(selectedNode as BookNode).year}` : selectedNode.novel} {selectedNode.description} {selectedNode.type !== 'book' && ( <> Relationships {selectedRelationships.map((rel, index) => ( {rel.description} Development: {rel.development.map((step, i) => ( {step} ))} ))} )} ) : ( Select a node to view details )} // ... existing JSX ... {selectedNode && ( {selectedNode.type === 'book' ? `Published: ${selectedNode.year}` : selectedNode.novel} )} // ... rest of the JSX ... ); }