diff --git a/src/pages/NetworkVisualization.tsx b/src/pages/NetworkVisualization.tsx index 68fba83..f6d9f38 100644 --- a/src/pages/NetworkVisualization.tsx +++ b/src/pages/NetworkVisualization.tsx @@ -1,23 +1,22 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'; import { characterNetwork } from '../data/character-network'; import { CharacterNode, Relationship, BookNode } from '../types/character-network'; -import { Box, Typography, Paper, Grid, Card, CardContent, IconButton, Tooltip, Popper, Chip, Divider, Container, CircularProgress, Fade } from '@mui/material'; +import { Box, Typography, Paper, Grid, IconButton, Tooltip, Chip, Divider, Container, CircularProgress, Fade } from '@mui/material'; import { ForceGraph2D } from 'react-force-graph'; -import { ArrowBack, Help, ZoomIn, ZoomOut, CenterFocusStrong, Search } from '@mui/icons-material'; +import { ArrowBack, Help, ZoomIn, ZoomOut, CenterFocusStrong } from '@mui/icons-material'; import * as d3 from 'd3'; -interface NetworkNode { +interface NetworkNode extends d3.SimulationNodeDatum { id: string; name: string; - x?: number; - y?: number; - val: number; - color: string; - type: string; + type: 'protagonist' | 'antagonist' | 'supporting' | 'book' | 'character'; description?: string; novel?: string; class?: string; - year?: number; + val: number; + color: string; + x?: number; + y?: number; fx?: number; fy?: number; } @@ -25,39 +24,65 @@ interface NetworkNode { interface NetworkLink { source: string; target: string; + type: string; color: string; } -interface GraphData { - nodes: NetworkNode[]; - links: NetworkLink[]; -} - -interface TooltipState { - open: boolean; - content: string; - x: number; - y: number; -} - // Proper typing for ForceGraph methods interface ForceGraphMethods { - zoom: (k: number) => void; - zoomToFit: (durationMs?: number, padding?: number) => void; - d3Force: (forceName: string, force: any) => void; + zoom: (k?: number) => number; + zoomToFit: (duration?: number, padding?: number) => void; + d3Force: (forceName: string, force?: d3.Force) => void; + d3ReheatSimulation: () => void; getZoom: () => number; - centerAt: (x?: number, y?: number, durationMs?: number) => void; } +type ForceGraphInstance = ForceGraphMethods; + +// Add sage theme colors after the interface definitions +const sageColors = { + primary: { + start: '#4A5D52', // darker sage + end: '#6B7F75' // lighter sage + }, + secondary: { + start: '#5B6E65', + end: '#7C8F86' + }, + tertiary: { + start: '#6B7F75', + end: '#8C9F96' + } +}; + +const relationshipColors = { + family: '#4A5D52', // sage green + romance: '#6B7F75', // medium sage + friendship: '#5B6E65', // light sage + rivalry: '#8C9F96' // pale sage +}; + +// Add utility function for pentagon layout +const getPentagonPoint = (index: number, total: number, radius: number, centerX: number, centerY: number) => { + // Start from the top (270 degrees) and go clockwise + // Adjust the starting angle to ensure one point is at the top + const startAngle = -90; // -90 degrees = top point + const angle = (startAngle + (360 / total) * index) * (Math.PI / 180); + return { + x: centerX + radius * Math.cos(angle), + y: centerY + radius * Math.sin(angle) + }; +}; + export default function NetworkVisualization() { const [selectedNode, setSelectedNode] = useState(null); const [selectedRelationships, setSelectedRelationships] = useState([]); const [selectedBook, setSelectedBook] = useState(null); - const [tooltip, setTooltip] = useState({ open: false, content: '', x: 0, y: 0 }); const containerRef = useRef(null); - const graphRef = useRef(); + const graphRef = useRef(null); 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(() => { @@ -75,37 +100,58 @@ export default function NetworkVisualization() { } }, [graphRef.current]); - const handleNodeHover = useCallback((node: NetworkNode | null) => { - if (node) { - const rect = containerRef.current?.getBoundingClientRect(); - if (rect) { - setTooltip({ - open: true, - content: `${node.name}\n${node.description || ''}`, - x: rect.left + rect.width / 2, // Center of container - y: rect.top + 100 // Fixed position from top - }); + // Add a useEffect to handle container resizing + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + const { width, height } = containerRef.current.getBoundingClientRect(); + setDimensions({ width, height }); } - } else { - setTooltip(prev => ({ ...prev, open: false })); - } + }; + + // 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 (!selectedBook && node.type === 'book') { - setSelectedBook(node.id); - setSelectedNode(characterNetwork.books.find(b => b.id === node.id) || null); - } else if (selectedBook) { + // Book node click + if (node.type === 'book') { + const bookNode = characterNetwork.books.find(b => b.id === node.id); + if (bookNode) { + // Update all states synchronously + setSelectedBook(node.id); + setSelectedNode(bookNode); + setSelectedRelationships([]); + + // Trigger force simulation update after state changes + requestAnimationFrame(() => { + if (graphRef.current) { + graphRef.current.d3ReheatSimulation(); + } + }); + } + return; + } + + // Character node click - only process if we're in a book view + if (selectedBook) { const characterNode = characterNetwork.nodes.find(n => n.id === node.id); if (characterNode) { - setSelectedNode(characterNode); + // Get relationships first const relations = characterNetwork.relationships.filter( r => r.source === node.id || r.target === node.id ); + + // Update states synchronously + setSelectedNode(characterNode); setSelectedRelationships(relations); } } - setTooltip(prev => ({ ...prev, open: false })); }, [selectedBook]); const handleBackClick = () => { @@ -114,116 +160,121 @@ export default function NetworkVisualization() { setSelectedRelationships([]); }; - const centerGraph = useCallback((graphData: GraphData) => { - const containerWidth = 800; - const containerHeight = 700; - const padding = 50; + // 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 xExtent = d3.extent(graphData.nodes, d => d.x || 0) as [number, number]; - const yExtent = d3.extent(graphData.nodes, d => d.y || 0) as [number, number]; - const xRange = xExtent[1] - xExtent[0] || 1; - const yRange = yExtent[1] - yExtent[0] || 1; + const x = dimensions.width / 2 + radius * Math.cos(angle); + const y = dimensions.height / 2 + radius * Math.sin(angle); - const scale = Math.min( - (containerWidth - 2 * padding) / xRange, - (containerHeight - 2 * padding) / yRange - ); - - graphData.nodes.forEach(node => { - node.x = ((node.x || 0) - (xExtent[0] + xRange / 2)) * scale + containerWidth / 2; - node.y = ((node.y || 0) - (yExtent[0] + yRange / 2)) * scale + containerHeight / 2; - }); - }, []); - - const getGraphData = useCallback((): GraphData => { - if (!selectedBook) { - // Book-level view with fixed positions in a circle - const data: GraphData = { - nodes: characterNetwork.books.map((book, idx) => { - const angle = (idx / characterNetwork.books.length) * 2 * Math.PI; - const radius = 150; // Reduced radius for better visibility return { - ...book, - val: 20, - // Set initial positions in a circle around center - x: Math.cos(angle) * radius + 400, - y: Math.sin(angle) * radius + 350, - // Don't fix positions to allow force layout to adjust - fx: undefined, - fy: undefined - }; - }) as NetworkNode[], - links: [] as NetworkLink[] - }; - return data; - } + ...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; + }); - const bookName = characterNetwork.books.find(b => b.id === selectedBook)?.name; - const nodes = characterNetwork.nodes - .filter(node => node.novel === bookName) - .map((node, idx) => { - const angle = (idx / characterNetwork.nodes.length) * 2 * Math.PI; - const radius = 100; // Initial radius for character layout - return { - ...node, - val: 10, - // Set initial positions in a circle - x: Math.cos(angle) * radius + 400, - y: Math.sin(angle) * radius + 350, - // Clear any fixed positions - fx: undefined, - fy: undefined, - color: node.type === 'protagonist' ? '#e91e63' : - node.type === 'antagonist' ? '#f44336' : '#2196f3' - }; - }) as NetworkNode[]; - - const data: GraphData = { - nodes, - links: characterNetwork.relationships - .filter(rel => - nodes.some((n: NetworkNode) => n.id === rel.source) && - nodes.some((n: NetworkNode) => n.id === rel.target) - ) + // 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 => ({ - source: rel.source, - target: rel.target, - color: rel.type === 'family' ? '#4caf50' : - rel.type === 'romance' ? '#e91e63' : - rel.type === 'friendship' ? '#2196f3' : '#ff9800' - })) as NetworkLink[] - }; + ...rel, + color: sageColors.primary.end, + curvature: 0.3 // Add consistent curve to all links + })); - return data; - }, [selectedBook]); + 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); - // Add glow effect for highlighted nodes - if (node.id === selectedNode?.id) { - ctx.shadowColor = node.color || '#fff'; - ctx.shadowBlur = 15; - } + // Save the current context state + ctx.save(); - // Draw node + // 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 = node.color; + ctx.fillStyle = gradient; ctx.fill(); - ctx.strokeStyle = '#fff'; - ctx.lineWidth = 2; + + // Add highlight ring + ctx.strokeStyle = lightenColor(baseColor, 40); + ctx.lineWidth = 1; ctx.stroke(); - // Clear shadow effect + // 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 Inter, Arial`; + ctx.font = `${fontSize}px "Cormorant", serif`; const textWidth = ctx.measureText(node.name).width; // Semi-transparent background for better readability @@ -244,7 +295,7 @@ export default function NetworkVisualization() { ); // Draw text - ctx.fillStyle = node.id === selectedNode?.id ? '#1976d2' : '#000'; + ctx.fillStyle = node.id === selectedNode?.id ? sageColors.primary.start : '#000'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText( @@ -253,6 +304,9 @@ export default function NetworkVisualization() { (node.y || 0) + labelDistance ); } + + // Restore the context state + ctx.restore(); }, [selectedNode]); // Helper function for drawing rounded rectangles @@ -278,60 +332,105 @@ export default function NetworkVisualization() { 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: - - Books:
- • Pride and Prejudice - Pink
- • Sense and Sensibility - Blue
- • Northanger Abbey - Green
- • Mansfield Park - Purple
- Longbourn - Brown + + + Color Legend: - - Characters:
- • Protagonists - Pink
- • Antagonists - Red
- • Supporting - Blue + + Nodes:
+ • Protagonists - {sageColors.primary.start}
+ • Antagonists - {sageColors.secondary.start}
+ • Supporting - {sageColors.tertiary.start}
- - Relationships:
- • Family - Green
- • Romance - Pink
- • Friendship - Blue
- • Rivalry - Orange + + Relationships:
+ • Family - {relationshipColors.family}
+ • Romance - {relationshipColors.romance}
+ • Friendship - {relationshipColors.friendship}
+ • Rivalry - {relationshipColors.rivalry}
); const handleZoomIn = () => { if (graphRef.current) { - const currentZoom = graphRef.current.getZoom(); - graphRef.current.zoom(currentZoom * 1.5); + const currentZoom = graphRef.current.zoom(); + graphRef.current.zoom(currentZoom * 1.2); } }; const handleZoomOut = () => { if (graphRef.current) { - const currentZoom = graphRef.current.getZoom(); - graphRef.current.zoom(currentZoom / 1.5); + const currentZoom = graphRef.current.zoom(); + graphRef.current.zoom(currentZoom / 1.2); } }; const handleCenterGraph = () => { if (graphRef.current) { - graphRef.current.zoomToFit(400, 50); + graphRef.current.zoomToFit(400); } }; + useEffect(() => { + if (graphRef.current) { + // Clear existing forces + graphRef.current.d3Force('charge', undefined); + graphRef.current.d3Force('center', undefined); + graphRef.current.d3Force('link', undefined); + + // Add stable forces with minimal movement + graphRef.current.d3Force('charge', d3.forceManyBody().strength(-100)); + graphRef.current.d3Force('center', d3.forceCenter(dimensions.width / 2, dimensions.height / 2).strength(0.05)); + graphRef.current.d3Force('link', d3.forceLink().distance(80).strength(0.2)); + + // Reduce simulation intensity + graphRef.current.d3Force('x', d3.forceX(dimensions.width / 2).strength(0.05)); + graphRef.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 (graphRef.current) { + // Center the graph with animation + requestAnimationFrame(() => { + graphRef.current?.zoomToFit(400, 100); // Increased padding for better centering + }); + } + }, [dimensions, selectedBook]); + return ( @@ -345,49 +444,45 @@ export default function NetworkVisualization() { borderColor: 'divider' }}> - - Character Network - + + + Character Network + + + Double click the nodes to access the information inside them! + + - + - - - - - - - - - - - - - - - - - `0 0 20px ${theme.palette.divider}` + bgcolor: '#fff', + border: '1px solid', + borderColor: 'divider', + '&:hover': { + borderColor: sageColors.primary.end, + transition: 'border-color 0.2s ease' + } }} ref={containerRef} > @@ -409,7 +509,7 @@ export default function NetworkVisualization() { left: 0, right: 0, bottom: 0, - bgcolor: 'rgba(255, 255, 255, 0.8)', + bgcolor: 'rgba(255, 255, 255, 0.9)', zIndex: 10, display: 'flex', alignItems: 'center', @@ -417,8 +517,14 @@ export default function NetworkVisualization() { flexDirection: 'column', gap: 2 }}> - - + + {selectedBook ? 'Loading character relationships...' : 'Loading books...'}
@@ -428,49 +534,36 @@ export default function NetworkVisualization() { link.color} - linkWidth={3} - nodeRelSize={8} - width={800} - height={700} + linkWidth={2} + nodeRelSize={6} + width={dimensions.width} + height={dimensions.height} cooldownTicks={50} cooldownTime={3000} - linkDirectionalParticles={3} - linkDirectionalParticleWidth={2} - linkDirectionalParticleSpeed={0.004} - d3AlphaDecay={0.1} - d3VelocityDecay={0.4} + d3AlphaDecay={0.02} + d3VelocityDecay={0.6} minZoom={0.5} maxZoom={4} - dagMode={selectedBook ? undefined : 'radialin'} - dagLevelDistance={100} - enablePanInteraction={true} - enableZoomInteraction={true} - onEngineStop={() => { - if (!selectedBook) { - centerGraph(getGraphData()); - } - }} + enableNodeDrag={true} onNodeDragEnd={(node: NetworkNode) => { if (node.x && node.y) { node.fx = node.x; node.fy = node.y; } }} - warmupTicks={100} - onZoom={(zoom: number) => { - if (!selectedBook) { - centerGraph(getGraphData()); - } + warmupTicks={0} + nodeLabel={(node: NetworkNode) => ''} + linkCurvature={0.3} + linkDirectionalParticles={0} + onEngineStop={() => { + // Ensure graph is centered after layout stabilizes + graphRef.current?.zoomToFit(400, 100); }} - enableNodeDrag={true} - enableZoomPanInteraction={true} /> @@ -487,10 +580,12 @@ export default function NetworkVisualization() { top: 16, left: 16, zIndex: 1, - bgcolor: 'background.paper', - boxShadow: 2, + bgcolor: 'white', + border: '1px solid', + borderColor: 'divider', + color: sageColors.primary.start, '&:hover': { - bgcolor: 'background.paper', + bgcolor: 'white', transform: 'scale(1.1)', transition: 'transform 0.2s' } @@ -505,23 +600,82 @@ export default function NetworkVisualization() { top: 16, right: 16, zIndex: 1, - bgcolor: 'background.paper', - p: 2, - borderRadius: 2, - boxShadow: 2, - maxWidth: 250, - backdropFilter: 'blur(8px)', - border: '1px solid', - borderColor: 'divider', - transition: 'opacity 0.3s', - opacity: isGraphReady ? 1 : 0 + display: 'flex', + flexDirection: 'column', + gap: 2, + alignItems: 'flex-end' }}> - - {!selectedBook ? 'Click a book to explore its characters' : 'Click characters to view relationships'} - + + 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'} + + )} @@ -530,14 +684,20 @@ export default function NetworkVisualization() { `0 0 20px ${theme.palette.divider}`, + bgcolor: 'white', + border: '1px solid', + borderColor: 'divider', position: 'sticky', - top: 24 + top: 24, + transition: 'all 0.3s ease', + '&:hover': { + borderColor: sageColors.primary.end, + transform: 'translateY(-2px)' + } }}> theme.palette.divider, + background: sageColors.primary.end, borderRadius: '4px', }, '&::-webkit-scrollbar-thumb:hover': { - background: theme => theme.palette.action.hover, + background: sageColors.primary.start, } }}> {selectedNode ? ( @@ -569,16 +729,26 @@ export default function NetworkVisualization() { {selectedNode.type !== 'book' && ( )} @@ -590,17 +760,21 @@ export default function NetworkVisualization() { {selectedNode.description} {selectedNode.type !== 'book' && ( <> - + Relationships @@ -609,19 +783,21 @@ export default function NetworkVisualization() { {selectedRelationships.map((rel, index) => ( - {rel.description} @@ -630,10 +806,8 @@ export default function NetworkVisualization() { size="small" sx={{ mb: 2, - bgcolor: rel.type === 'family' ? 'success.light' : - rel.type === 'romance' ? 'error.light' : - rel.type === 'friendship' ? 'primary.light' : 'warning.light', - color: '#fff', + bgcolor: relationshipColors[rel.type], + color: 'white', borderRadius: 1, fontFamily: '"Lato", sans-serif' }} @@ -641,22 +815,24 @@ export default function NetworkVisualization() { Development: {rel.development.map((step, i) => ( - {step} @@ -678,7 +854,10 @@ export default function NetworkVisualization() { height: '100%', color: 'text.secondary' }}> - + Select a node to view details @@ -688,35 +867,6 @@ export default function NetworkVisualization() { - - - - - {tooltip.content} - - - ); }