diff --git a/src/pages/NetworkVisualization.tsx b/src/pages/NetworkVisualization.tsx index ee032f7..d8143a4 100644 --- a/src/pages/NetworkVisualization.tsx +++ b/src/pages/NetworkVisualization.tsx @@ -3,6 +3,7 @@ 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'; @@ -25,7 +26,7 @@ interface BaseNetworkNode extends d3.SimulationNodeDatum { // Book-specific node interface interface NetworkBookNode extends BaseNetworkNode { type: 'book'; - year: number; + year: string; } // Character-specific node interface @@ -36,10 +37,9 @@ interface NetworkCharacterNode extends BaseNetworkNode { // Union type for all possible node types type NetworkNode = NetworkBookNode | NetworkCharacterNode; -// Updated NetworkLink interface to match D3's expectations interface NetworkLink extends d3.SimulationLinkDatum { - source: string | number | NetworkNode; - target: string | number | NetworkNode; + source: string | NetworkNode; + target: string | NetworkNode; type: string; color: string; } @@ -51,6 +51,8 @@ interface GraphParticle { target: NetworkNode; } +// Proper typing for ForceGraph methods +interface ForceGraphMethods = NetworkLink> { // Complete ForceGraph methods interface interface ForceGraphMethods { zoom: (k?: number) => number; @@ -74,11 +76,785 @@ 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 && (