mirror of
https://github.com/harivansh-afk/Austens-Wedding-Guide.git
synced 2026-04-18 08:02:51 +00:00
improved literary analysis pgae and added compare works sub page
This commit is contained in:
parent
0134c81526
commit
5ca37d03f2
13 changed files with 3953 additions and 59 deletions
461
src/pages/NetworkVisualization.tsx
Normal file
461
src/pages/NetworkVisualization.tsx
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
import React, { useState, useCallback, useRef } 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 } from '@mui/material';
|
||||
import { ForceGraph2D } from 'react-force-graph';
|
||||
import { ArrowBack, Help } from '@mui/icons-material';
|
||||
import * as d3 from 'd3';
|
||||
|
||||
interface NetworkNode {
|
||||
id: string;
|
||||
name: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
val: number;
|
||||
color: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
novel?: string;
|
||||
class?: string;
|
||||
year?: number;
|
||||
fx?: number;
|
||||
fy?: number;
|
||||
}
|
||||
|
||||
interface NetworkLink {
|
||||
source: string;
|
||||
target: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface GraphData {
|
||||
nodes: NetworkNode[];
|
||||
links: NetworkLink[];
|
||||
}
|
||||
|
||||
interface TooltipState {
|
||||
open: boolean;
|
||||
content: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export default function NetworkVisualization() {
|
||||
const [selectedNode, setSelectedNode] = useState<CharacterNode | BookNode | null>(null);
|
||||
const [selectedRelationships, setSelectedRelationships] = useState<Relationship[]>([]);
|
||||
const [selectedBook, setSelectedBook] = useState<string | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState>({ open: false, content: '', x: 0, y: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setTooltip(prev => ({ ...prev, open: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
const characterNode = characterNetwork.nodes.find(n => n.id === node.id);
|
||||
if (characterNode) {
|
||||
setSelectedNode(characterNode);
|
||||
const relations = characterNetwork.relationships.filter(
|
||||
r => r.source === node.id || r.target === node.id
|
||||
);
|
||||
setSelectedRelationships(relations);
|
||||
}
|
||||
}
|
||||
setTooltip(prev => ({ ...prev, open: false }));
|
||||
}, [selectedBook]);
|
||||
|
||||
const handleBackClick = () => {
|
||||
setSelectedBook(null);
|
||||
setSelectedNode(null);
|
||||
setSelectedRelationships([]);
|
||||
};
|
||||
|
||||
const centerGraph = useCallback((graphData: GraphData) => {
|
||||
const containerWidth = 800;
|
||||
const containerHeight = 700;
|
||||
const padding = 50;
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
.map(rel => ({
|
||||
source: rel.source,
|
||||
target: rel.target,
|
||||
color: rel.type === 'family' ? '#4caf50' :
|
||||
rel.type === 'romance' ? '#e91e63' :
|
||||
rel.type === 'friendship' ? '#2196f3' : '#ff9800'
|
||||
})) as NetworkLink[]
|
||||
};
|
||||
|
||||
return data;
|
||||
}, [selectedBook]);
|
||||
|
||||
const renderNodeCanvas = useCallback((node: NetworkNode, ctx: CanvasRenderingContext2D, scale: number) => {
|
||||
const size = node.val || 5;
|
||||
const fontSize = Math.max(12 / scale, 2);
|
||||
|
||||
// Draw node
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x || 0, node.y || 0, size, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// 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`;
|
||||
const textWidth = ctx.measureText(node.name).width;
|
||||
|
||||
// Semi-transparent background for better readability
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
|
||||
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 = '#000';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
node.name,
|
||||
node.x || 0,
|
||||
(node.y || 0) + labelDistance
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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();
|
||||
};
|
||||
|
||||
const getLegendTooltip = () => (
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Color Legend:</Typography>
|
||||
<Typography variant="body2">
|
||||
Books:<br />
|
||||
• Pride and Prejudice - Pink<br />
|
||||
• Sense and Sensibility - Blue<br />
|
||||
• Northanger Abbey - Green<br />
|
||||
• Mansfield Park - Purple<br />
|
||||
Longbourn - Brown
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Characters:<br />
|
||||
• Protagonists - Pink<br />
|
||||
• Antagonists - Red<br />
|
||||
• Supporting - Blue
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Relationships:<br />
|
||||
• Family - Green<br />
|
||||
• Romance - Pink<br />
|
||||
• Friendship - Blue<br />
|
||||
• Rivalry - Orange
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
px: 3,
|
||||
pt: 2
|
||||
}}>
|
||||
<Typography variant="h4">
|
||||
Character Network & Timeline
|
||||
</Typography>
|
||||
<Tooltip title={getLegendTooltip()} arrow placement="right">
|
||||
<IconButton size="small" sx={{ ml: 2 }}>
|
||||
<Help />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={0} sx={{ flex: 1, minHeight: 0 }}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
height: '100%',
|
||||
minHeight: 700,
|
||||
position: 'relative',
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
ref={containerRef}
|
||||
>
|
||||
{selectedBook && (
|
||||
<Tooltip title="Return to book overview" arrow>
|
||||
<IconButton
|
||||
onClick={handleBackClick}
|
||||
sx={{ position: 'absolute', top: 8, left: 8, zIndex: 1 }}
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 1,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.9)',
|
||||
p: 1,
|
||||
borderRadius: 1
|
||||
}}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{!selectedBook ? 'Click a book to explore its characters' : 'Click characters to view relationships'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ForceGraph2D
|
||||
graphData={getGraphData()}
|
||||
onNodeHover={handleNodeHover}
|
||||
onNodeClick={handleNodeClick}
|
||||
nodeCanvasObject={(node: NetworkNode, ctx: CanvasRenderingContext2D, scale: number) =>
|
||||
renderNodeCanvas(node, ctx, scale)}
|
||||
linkColor={(link: NetworkLink) => link.color}
|
||||
linkWidth={2}
|
||||
nodeRelSize={6}
|
||||
width={800}
|
||||
height={700}
|
||||
cooldownTicks={50}
|
||||
cooldownTime={3000}
|
||||
linkDirectionalParticles={2}
|
||||
linkDirectionalParticleSpeed={0.003}
|
||||
d3AlphaDecay={0.1}
|
||||
d3VelocityDecay={0.4}
|
||||
minZoom={0.5}
|
||||
maxZoom={4}
|
||||
dagMode={selectedBook ? undefined : 'radialin'}
|
||||
dagLevelDistance={100}
|
||||
enablePanInteraction={true}
|
||||
enableZoomInteraction={true}
|
||||
onEngineStop={() => {
|
||||
if (!selectedBook) {
|
||||
centerGraph(getGraphData());
|
||||
}
|
||||
}}
|
||||
onNodeDragEnd={(node: NetworkNode) => {
|
||||
if (node.x && node.y) {
|
||||
node.fx = node.x;
|
||||
node.fy = node.y;
|
||||
}
|
||||
}}
|
||||
warmupTicks={100}
|
||||
onZoom={() => centerGraph(getGraphData())}
|
||||
centerAt={[400, 350]}
|
||||
zoom={2}
|
||||
enableNodeDrag={true}
|
||||
enableZoomPanInteraction={true}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
minHeight: 700,
|
||||
overflow: 'auto',
|
||||
borderLeft: '1px solid rgba(0, 0, 0, 0.12)'
|
||||
}}>
|
||||
{selectedNode && (
|
||||
<Card sx={{ boxShadow: 'none', borderRadius: 0 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{selectedNode.name}
|
||||
</Typography>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
{selectedNode.type === 'book' ? `Published: ${(selectedNode as BookNode).year}` : selectedNode.novel}
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
{selectedNode.description}
|
||||
</Typography>
|
||||
|
||||
{selectedNode.type !== 'book' && (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ mt: 2 }} color="textSecondary">
|
||||
Social Class: {(selectedNode as CharacterNode).class}
|
||||
<br />
|
||||
Character Role: {selectedNode.type}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 2 }}>
|
||||
Relationships
|
||||
</Typography>
|
||||
{selectedRelationships.map((rel, index) => (
|
||||
<Box key={index} sx={{ mt: 2, p: 2, bgcolor: 'rgba(0, 0, 0, 0.02)', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle1" color="primary">
|
||||
{rel.description}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
Type: {rel.type}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Development:
|
||||
</Typography>
|
||||
<ul style={{ margin: '8px 0', paddingLeft: 20 }}>
|
||||
{rel.development.map((step, i) => (
|
||||
<li key={i}>
|
||||
<Typography variant="body2">{step}</Typography>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Popper
|
||||
open={tooltip.open}
|
||||
anchorEl={containerRef.current}
|
||||
placement="top"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${tooltip.x}px`,
|
||||
top: `${tooltip.y}px`,
|
||||
zIndex: 1500
|
||||
}}
|
||||
>
|
||||
<Paper sx={{
|
||||
p: 2,
|
||||
maxWidth: 300,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.95)',
|
||||
boxShadow: 3,
|
||||
borderRadius: 1
|
||||
}}>
|
||||
<Typography variant="body2" whiteSpace="pre-line">
|
||||
{tooltip.content}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Popper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue