updated interactive timeline and added social class view

This commit is contained in:
Harivansh Rathi 2024-12-04 14:40:29 -05:00
parent 9609248b69
commit 59657ce287
8 changed files with 2063 additions and 0 deletions

View file

@ -58,6 +58,18 @@ const Navbar = () => {
>
Character Network
</Link>
<Link
to="/timeline"
className="bg-sage-100 text-sage-700 hover:bg-sage-200 px-4 py-2 rounded-md transition-colors"
>
Timeline
</Link>
<Link
to="/social-class"
className="bg-sage-100 text-sage-700 hover:bg-sage-200 px-4 py-2 rounded-md transition-colors"
>
Social Class
</Link>
</div>
</nav>
</div>

View file

@ -0,0 +1,253 @@
import React, { useState } from 'react';
import { Box, Typography, Paper, Grid, Tooltip, Card, CardContent, Chip, Dialog, DialogTitle, DialogContent } from '@mui/material';
import { SocialClass, Character } from '../types/timeline';
const socialClasses: SocialClass[] = [
{
name: 'Upper Class',
description: 'The landed gentry and aristocracy of Regency England',
incomeRange: '£4,000-10,000 per annum',
modernEquivalent: '$200,000-500,000 per year',
characteristics: [
'Inherited estates and land',
'No need to work for income',
'Expected to maintain country estates',
'Social obligations to tenants and community'
],
examples: [
{
character: 'Mr. Darcy',
novel: 'Pride and Prejudice',
context: 'Owner of Pemberley estate with £10,000 per year'
},
{
character: 'Sir Thomas Bertram',
novel: 'Mansfield Park',
context: 'Owner of Mansfield Park and plantations in Antigua'
}
]
},
{
name: 'Middle Class',
description: 'Professional class including clergy, military officers, and successful merchants',
incomeRange: '£200-1,000 per annum',
modernEquivalent: '$30,000-150,000 per year',
characteristics: [
'Professional occupations',
'Education but no inherited wealth',
'Aspiring to climb social ladder',
'Emphasis on manners and propriety'
],
examples: [
{
character: 'Mr. Bennet',
novel: 'Pride and Prejudice',
context: 'Country gentleman with £2,000 per year'
},
{
character: 'Henry Tilney',
novel: 'Northanger Abbey',
context: 'Clergyman with independent means'
}
]
},
{
name: 'Working Class',
description: 'Servants, laborers, and small traders',
incomeRange: '£20-100 per annum',
modernEquivalent: '$5,000-20,000 per year',
characteristics: [
'Manual labor or service positions',
'Limited education and opportunities',
'Dependent on employers',
'Focus on survival and basic needs'
],
examples: [
{
character: 'The Hill Family',
novel: 'Longbourn',
context: 'Servants at the Bennet household'
},
{
character: 'Hannah',
novel: 'Northanger Abbey',
context: 'Servant at the Tilney household'
}
]
}
];
const characters: Character[] = [
{
id: 'darcy',
name: 'Mr. Darcy',
novel: 'Pride and Prejudice',
socialClass: 'upper',
occupation: 'Landowner',
annualIncome: '£10,000',
modernEquivalent: '$500,000',
description: 'Wealthy landowner of Pemberley estate',
relationships: ['elizabeth-bennet', 'georgiana-darcy']
},
{
id: 'elizabeth',
name: 'Elizabeth Bennet',
novel: 'Pride and Prejudice',
socialClass: 'middle',
occupation: 'Gentleman\'s daughter',
annualIncome: 'Share of £2,000',
modernEquivalent: '$10,000',
description: 'Intelligent and witty second daughter of the Bennet family',
relationships: ['darcy', 'jane-bennet']
},
{
id: 'sarah',
name: 'Sarah',
novel: 'Longbourn',
socialClass: 'working',
occupation: 'Housemaid',
annualIncome: '£8',
modernEquivalent: '$2,000',
description: 'Hardworking housemaid at Longbourn',
relationships: ['mrs-hill', 'james-smith']
}
];
export default function SocialClassView() {
const [selectedCharacter, setSelectedCharacter] = useState<Character | null>(null);
const [comparisonCharacter, setComparisonCharacter] = useState<Character | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const handleCharacterClick = (character: Character) => {
if (!selectedCharacter) {
setSelectedCharacter(character);
} else if (selectedCharacter.id !== character.id) {
setComparisonCharacter(character);
setDialogOpen(true);
}
};
const handleCloseDialog = () => {
setDialogOpen(false);
setSelectedCharacter(null);
setComparisonCharacter(null);
};
return (
<Box sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>
Social Class in Austen's Novels
</Typography>
{/* Social Pyramid */}
<Box sx={{ mb: 6, position: 'relative', height: 300 }}>
{socialClasses.map((socialClass, index) => (
<Tooltip
key={socialClass.name}
title={
<Box>
<Typography variant="subtitle2">{socialClass.name}</Typography>
<Typography variant="body2">{socialClass.description}</Typography>
<Typography variant="caption">Income: {socialClass.incomeRange}</Typography>
</Box>
}
arrow
>
<Paper
elevation={3}
sx={{
position: 'absolute',
left: '50%',
width: `${100 - index * 20}%`,
height: 80,
transform: `translateX(-50%) translateY(${index * 100}px)`,
bgcolor: `primary.${100 + index * 100}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
transform: `translateX(-50%) translateY(${index * 100}px) scale(1.02)`,
}
}}
>
<Typography variant="h6" color="text.primary">
{socialClass.name}
</Typography>
</Paper>
</Tooltip>
))}
</Box>
{/* Character Grid */}
<Typography variant="h6" gutterBottom sx={{ mt: 4 }}>
Character Examples {selectedCharacter && '(Select another character to compare)'}
</Typography>
<Grid container spacing={2}>
{characters.map((character) => (
<Grid item xs={12} sm={6} md={4} key={character.id}>
<Card
onClick={() => handleCharacterClick(character)}
sx={{
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
transform: 'scale(1.02)',
},
bgcolor: selectedCharacter?.id === character.id ? 'primary.50' : 'background.paper'
}}
>
<CardContent>
<Typography variant="h6" gutterBottom>
{character.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{character.novel}
</Typography>
<Chip
label={character.socialClass.charAt(0).toUpperCase() + character.socialClass.slice(1)}
size="small"
sx={{ mb: 1 }}
/>
<Typography variant="body2">
Annual Income: {character.annualIncome}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
Modern Equivalent: {character.modernEquivalent}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* Comparison Dialog */}
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<DialogTitle>Character Comparison</DialogTitle>
<DialogContent>
{selectedCharacter && comparisonCharacter && (
<Grid container spacing={3}>
<Grid item xs={6}>
<Typography variant="h6" gutterBottom>{selectedCharacter.name}</Typography>
<Typography variant="body2" gutterBottom>Novel: {selectedCharacter.novel}</Typography>
<Typography variant="body2" gutterBottom>Class: {selectedCharacter.socialClass}</Typography>
<Typography variant="body2" gutterBottom>Income: {selectedCharacter.annualIncome}</Typography>
<Typography variant="body2" gutterBottom>Modern: {selectedCharacter.modernEquivalent}</Typography>
<Typography variant="body2">{selectedCharacter.description}</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="h6" gutterBottom>{comparisonCharacter.name}</Typography>
<Typography variant="body2" gutterBottom>Novel: {comparisonCharacter.novel}</Typography>
<Typography variant="body2" gutterBottom>Class: {comparisonCharacter.socialClass}</Typography>
<Typography variant="body2" gutterBottom>Income: {comparisonCharacter.annualIncome}</Typography>
<Typography variant="body2" gutterBottom>Modern: {comparisonCharacter.modernEquivalent}</Typography>
<Typography variant="body2">{comparisonCharacter.description}</Typography>
</Grid>
</Grid>
)}
</DialogContent>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1,894 @@
import React, { useState, useRef, useEffect } from 'react';
import { Box, Typography, Paper, ButtonGroup, Button, IconButton, Divider, Chip } from '@mui/material';
import { TimelineEvent } from '../../types/timeline';
import { DragIndicator, ChevronLeft, ChevronRight } from '@mui/icons-material';
import { alpha } from '@mui/material/styles';
interface Props {
events: TimelineEvent[];
}
// Define major time periods with matching category colors
const timePeriods = [
{ start: 1775, end: 1785, name: 'Early Life', color: 'primary.50' },
{ start: 1786, end: 1800, name: 'Juvenilia & Early Drafts', color: 'primary.100' },
{ start: 1801, end: 1817, name: 'Publication Years', color: 'primary.200' },
{ start: 1818, end: 1900, name: 'Victorian Reception', color: 'warning.50' },
{ start: 1901, end: 2000, name: '20th Century', color: 'success.50' },
{ start: 2001, end: new Date().getFullYear(), name: 'Contemporary', color: 'success.100' }
];
// Timeline data focused on course texts and their context
const timelineEvents: TimelineEvent[] = [
// Works - Published and Unpublished
{
year: 1787,
type: 'works',
title: 'Love and Freindship',
description: "Early epistolary work from Austen's juvenilia, showing her early satirical style",
significance: 'Demonstrates Austen\'s early critique of sensibility and romantic conventions'
},
{
year: 1795,
type: 'works',
title: 'Elinor and Marianne (First Draft)',
description: 'Early epistolary version of what would become Sense and Sensibility',
significance: 'Shows development of Austen\'s craft from letters to narrative form'
},
{
year: 1797,
type: 'works',
title: 'First Impressions (Original P&P)',
description: 'Original version of Pride and Prejudice, rejected by publisher Thomas Cadell',
significance: 'Reveals the evolution of her most famous work'
},
{
year: 1811,
type: 'works',
title: 'Sense and Sensibility Published',
description: 'Austen\'s first published novel explores the tension between emotional expression and rational restraint through the Dashwood sisters.',
novel: 'sense-and-sensibility',
significance: 'Establishes Austen\'s recurring theme of balancing heart and mind in relationships.'
},
{
year: 1813,
type: 'works',
title: 'Pride and Prejudice Published',
description: 'A masterful examination of hasty judgments and social prejudices through Elizabeth Bennet\'s journey.',
novel: 'pride-and-prejudice',
significance: 'Critiques the marriage market while exploring personal growth and social mobility.'
},
{
year: 1814,
type: 'works',
title: 'Mansfield Park Published',
description: 'Through Fanny Price\'s story, Austen examines moral integrity in a materialistic society.',
novel: 'mansfield-park',
significance: 'Presents Austen\'s most direct critique of social corruption and moral decay.'
},
{
year: 1818,
type: 'works',
title: 'Northanger Abbey Published (Posthumously)',
description: 'A playful parody of Gothic novels that examines the relationship between fiction and reality.',
novel: 'northanger-abbey',
significance: 'Demonstrates Austen\'s literary awareness and critique of reading practices.'
},
// Historical and Cultural Context
{
year: 1792,
type: 'context',
title: 'Vindication of the Rights of Woman',
description: 'Mary Wollstonecraft publishes feminist treatise',
significance: 'Contemporary debates about women\'s education and rights that appear in Austen\'s works'
},
{
year: 1795,
type: 'context',
title: 'Marriage Act Amendment',
description: 'Required separate residence for 6 weeks before marriage, affecting courtship practices.',
significance: 'Provides legal context for marriage plots and elopement concerns in Pride and Prejudice and Mansfield Park.'
},
{
year: 1799,
type: 'context',
title: 'Income Tax Introduction',
description: 'First British income tax introduced to fund Napoleonic Wars',
significance: 'Economic context for character incomes mentioned in novels'
},
{
year: 1801,
type: 'context',
title: 'Move to Bath',
description: 'Austen family relocates to Bath, a period of reduced writing',
significance: 'Influenced her portrayal of Bath in Northanger Abbey and other works'
},
{
year: 1805,
type: 'context',
title: 'Battle of Trafalgar & Death of Rev. Austen',
description: 'Major naval victory and death of Jane\'s father leading to financial uncertainty',
significance: 'Influences on naval themes and women\'s financial dependence in her novels'
},
// Legacy and Critical Reception
{
year: 1870,
type: 'legacy',
title: 'Memoir of Jane Austen',
description: 'James Edward Austen-Leigh publishes first major biography',
significance: 'Shaped Victorian and later reception of Austen'
},
{
year: 2009,
type: 'legacy',
title: 'A Truth Universally Acknowledged',
description: 'Collection of critical essays on why we read Jane Austen',
significance: 'Modern critical perspectives on Austen\'s enduring appeal'
},
// Modern Adaptations and Retellings
{
year: 1995,
type: 'adaptations',
title: 'BBC Pride and Prejudice',
description: 'Colin Firth/Jennifer Ehle adaptation',
significance: 'Influential adaptation that sparked renewed interest in Austen'
},
{
year: 2008,
type: 'adaptations',
title: 'Lost in Austen',
description: 'Time-travel adaptation mixing modern perspective with P&P',
significance: 'Example of creative modern reinterpretation mentioned in course'
},
{
year: 2013,
type: 'adaptations',
title: 'Longbourn by Jo Baker',
description: 'A retelling of Pride and Prejudice from the servants\' perspective',
significance: 'Offers a "downstairs" perspective on Austen\'s world'
},
{
year: 2015,
type: 'adaptations',
title: 'Pride by Ibi Zoboi',
description: 'A contemporary YA retelling of Pride and Prejudice set in Brooklyn',
significance: 'Demonstrates the continued relevance of Austen\'s themes in modern, diverse contexts'
}
];
export default function InteractiveTimeline({ events = timelineEvents }: Props) {
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
const [activeFilters, setActiveFilters] = useState<string[]>(['works', 'context', 'legacy', 'adaptations']);
const [dragPosition, setDragPosition] = useState<number>(50);
const timelineRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
// Extended year range
const displayMinYear = Math.min(...events.map(e => e.year)) - 10;
const displayMaxYear = Math.max(...events.map(e => e.year)) + 10;
const actualMinYear = displayMinYear;
const actualMaxYear = displayMaxYear;
const timeSpan = actualMaxYear - actualMinYear;
// Generate year markers
const yearMarkers = [];
const yearStep = Math.ceil(timeSpan / 15);
for (let year = actualMinYear; year <= actualMaxYear; year += yearStep) {
yearMarkers.push(year);
}
if (yearMarkers[yearMarkers.length - 1] !== actualMaxYear) {
yearMarkers.push(actualMaxYear);
}
// Add scroll position tracking
useEffect(() => {
const handleScroll = () => {
if (timelineRef.current) {
const { scrollLeft } = timelineRef.current;
const totalWidth = timelineRef.current.scrollWidth;
const position = (scrollLeft / totalWidth) * 100;
setDragPosition(position);
}
};
timelineRef.current?.addEventListener('scroll', handleScroll);
return () => timelineRef.current?.removeEventListener('scroll', handleScroll);
}, []);
const navigateEvents = (direction: 'prev' | 'next') => {
// Sort filtered events chronologically
const filteredEvents = events
.filter(event => activeFilters.includes(event.type))
.sort((a, b) => a.year - b.year);
if (filteredEvents.length === 0) return;
if (!selectedEvent) {
setSelectedEvent(direction === 'next' ? filteredEvents[0] : filteredEvents[filteredEvents.length - 1]);
return;
}
// Find the current event's index in the filtered array
const currentIndex = filteredEvents.findIndex(e => e.year === selectedEvent.year && e.title === selectedEvent.title);
let newIndex;
if (direction === 'next') {
newIndex = currentIndex < filteredEvents.length - 1 ? currentIndex + 1 : 0;
} else {
newIndex = currentIndex > 0 ? currentIndex - 1 : filteredEvents.length - 1;
}
const newEvent = filteredEvents[newIndex];
setSelectedEvent(newEvent);
// Calculate new drag position and scroll into view
if (timelineRef.current) {
const timelineWidth = timelineRef.current.clientWidth;
const totalWidth = timelineRef.current.scrollWidth;
const newPosition = ((newEvent.year - actualMinYear) / timeSpan) * 100;
setDragPosition(newPosition);
const scrollPosition = (newPosition / 100) * totalWidth - (timelineWidth / 2);
timelineRef.current.scrollTo({
left: scrollPosition,
behavior: 'smooth'
});
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging.current && timelineRef.current) {
const rect = timelineRef.current.getBoundingClientRect();
const scrollLeft = timelineRef.current.scrollLeft;
const position = ((e.clientX - rect.left + scrollLeft) / (rect.width * 3)) * 100;
setDragPosition(Math.max(0, Math.min(100, position)));
const currentYear = (position / 100) * timeSpan + actualMinYear;
// Only consider events that are currently filtered
const filteredEvents = events.filter(event => activeFilters.includes(event.type));
// Find nearest event among filtered events
const nearestEvent = filteredEvents.reduce((prev, curr) => {
return Math.abs(curr.year - currentYear) < Math.abs(prev.year - currentYear) ? curr : prev;
}, filteredEvents[0]);
// Only set selected event if we have filtered events and are within 1 year of the nearest event
if (filteredEvents.length > 0 && Math.abs(nearestEvent.year - currentYear) <= 1) {
setSelectedEvent(nearestEvent);
} else {
setSelectedEvent(null);
}
}
};
const handleMouseDown = (e: React.MouseEvent) => {
if (timelineRef.current) {
isDragging.current = true;
const rect = timelineRef.current.getBoundingClientRect();
const scrollLeft = timelineRef.current.scrollLeft;
const position = ((e.clientX - rect.left + scrollLeft) / (rect.width * 3)) * 100;
setDragPosition(Math.max(0, Math.min(100, position)));
const currentYear = (position / 100) * timeSpan + actualMinYear;
// Only consider events that are currently filtered
const filteredEvents = events.filter(event => activeFilters.includes(event.type));
// Find nearest event among filtered events
const nearestEvent = filteredEvents.reduce((prev, curr) => {
return Math.abs(curr.year - currentYear) < Math.abs(prev.year - currentYear) ? curr : prev;
}, filteredEvents[0]);
// Only set selected event if we have filtered events and are within 1 year of the nearest event
if (filteredEvents.length > 0 && Math.abs(nearestEvent.year - currentYear) <= 1) {
setSelectedEvent(nearestEvent);
} else {
setSelectedEvent(null);
}
}
};
const handleMouseUp = () => {
isDragging.current = false;
};
const handleMouseLeave = () => {
isDragging.current = false;
};
const toggleFilter = (filter: string) => {
setActiveFilters(prev =>
prev.includes(filter)
? prev.filter(f => f !== filter)
: [...prev, filter]
);
};
const getEventColor = (type: string) => {
switch (type) {
case 'works':
return { main: 'primary.main', light: 'primary.50' };
case 'context':
return { main: 'secondary.main', light: 'secondary.50' };
case 'legacy':
return { main: 'warning.main', light: 'warning.50' };
case 'adaptations':
return { main: 'success.main', light: 'success.50' };
default:
return { main: 'grey.500', light: 'grey.50' };
}
};
const filteredEvents = events.filter(event => activeFilters.includes(event.type));
// Add click handler for events
const handleEventClick = (event: TimelineEvent) => {
if (activeFilters.includes(event.type)) {
setSelectedEvent(event);
// Update drag position to match clicked event
const newPosition = ((event.year - actualMinYear) / timeSpan) * 100;
setDragPosition(newPosition);
// Scroll to the clicked event
if (timelineRef.current) {
const timelineWidth = timelineRef.current.clientWidth;
const totalWidth = timelineRef.current.scrollWidth;
const scrollPosition = (newPosition / 100) * totalWidth - (timelineWidth / 2);
timelineRef.current.scrollTo({
left: scrollPosition,
behavior: 'smooth'
});
}
}
};
return (
<Box sx={{ p: 3 }}>
{/* Filters */}
<Box sx={{ mb: 4 }}>
<ButtonGroup variant="outlined" size="small">
<Button
onClick={() => toggleFilter('works')}
variant={activeFilters.includes('works') ? 'contained' : 'outlined'}
sx={{
color: activeFilters.includes('works') ? 'white' : 'primary.main',
borderColor: 'primary.main',
'&:hover': {
borderColor: 'primary.dark',
bgcolor: activeFilters.includes('works') ? 'primary.dark' : 'primary.50'
}
}}
>
Austen's Works
</Button>
<Button
onClick={() => toggleFilter('context')}
variant={activeFilters.includes('context') ? 'contained' : 'outlined'}
sx={{
color: activeFilters.includes('context') ? 'white' : 'secondary.main',
borderColor: 'secondary.main',
bgcolor: activeFilters.includes('context') ? 'secondary.main' : 'transparent',
'&:hover': {
borderColor: 'secondary.dark',
bgcolor: activeFilters.includes('context') ? 'secondary.dark' : 'secondary.50'
}
}}
>
Historical Context
</Button>
<Button
onClick={() => toggleFilter('legacy')}
variant={activeFilters.includes('legacy') ? 'contained' : 'outlined'}
sx={{
color: activeFilters.includes('legacy') ? 'white' : 'warning.main',
borderColor: 'warning.main',
bgcolor: activeFilters.includes('legacy') ? 'warning.main' : 'transparent',
'&:hover': {
borderColor: 'warning.dark',
bgcolor: activeFilters.includes('legacy') ? 'warning.dark' : 'warning.50'
}
}}
>
Legacy & Reception
</Button>
<Button
onClick={() => toggleFilter('adaptations')}
variant={activeFilters.includes('adaptations') ? 'contained' : 'outlined'}
sx={{
color: activeFilters.includes('adaptations') ? 'white' : 'success.main',
borderColor: 'success.main',
bgcolor: activeFilters.includes('adaptations') ? 'success.main' : 'transparent',
'&:hover': {
borderColor: 'success.dark',
bgcolor: activeFilters.includes('adaptations') ? 'success.dark' : 'success.50'
}
}}
>
Modern Adaptations
</Button>
</ButtonGroup>
</Box>
{/* Timeline Container */}
<Box sx={{ position: 'relative', mt: 6 }}>
{/* Navigation Buttons Container */}
<Box
sx={{
position: 'absolute',
top: 200, // Half of the timeline height (400px)
left: 0,
right: 0,
height: 0, // Zero height to not affect layout
pointerEvents: 'none',
zIndex: 3
}}
>
<IconButton
onClick={() => navigateEvents('prev')}
sx={{
position: 'absolute',
left: 0,
top: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 3,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
boxShadow: theme => `0 2px 8px ${alpha(theme.palette.common.black, 0.15)}`,
width: 40,
height: 40,
pointerEvents: 'auto',
'&:hover': {
bgcolor: 'grey.50',
boxShadow: theme => `0 4px 12px ${alpha(theme.palette.common.black, 0.2)}`
},
'&:active': {
bgcolor: 'grey.100'
}
}}
>
<ChevronLeft />
</IconButton>
<IconButton
onClick={() => navigateEvents('next')}
sx={{
position: 'absolute',
right: 0,
top: '50%',
transform: 'translate(50%, -50%)',
zIndex: 3,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
boxShadow: theme => `0 2px 8px ${alpha(theme.palette.common.black, 0.15)}`,
width: 40,
height: 40,
pointerEvents: 'auto',
'&:hover': {
bgcolor: 'grey.50',
boxShadow: theme => `0 4px 12px ${alpha(theme.palette.common.black, 0.2)}`
},
'&:active': {
bgcolor: 'grey.100'
}
}}
>
<ChevronRight />
</IconButton>
</Box>
{/* Time Period Labels */}
<Box sx={{
position: 'absolute',
top: -60,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'space-between',
px: 2,
mb: 2,
overflow: 'hidden'
}}>
{timePeriods.map((period) => {
const startPos = ((period.start - actualMinYear) / timeSpan) * 100;
const width = ((period.end - period.start) / timeSpan) * 100;
return (
<Box
key={period.name}
sx={{
position: 'absolute',
left: `${startPos}%`,
width: `${width}%`,
textAlign: 'center'
}}
>
<Paper
elevation={0}
sx={{
display: 'inline-block',
bgcolor: period.color,
border: '1px solid',
borderColor: 'divider',
px: 2,
py: 0.5,
borderRadius: '16px',
whiteSpace: 'nowrap',
fontFamily: 'cormorant',
}}
>
<Typography
variant="caption"
sx={{
color: theme => theme.palette.text.primary,
fontWeight: 500,
fontSize: '0.875rem'
}}
>
{period.name}
</Typography>
</Paper>
</Box>
);
})}
</Box>
{/* Main Timeline */}
<Box
ref={timelineRef}
sx={{
position: 'relative',
height: 400,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: theme => `0 2px 12px ${alpha(theme.palette.common.black, 0.08)}`,
overflow: 'hidden',
cursor: 'pointer',
userSelect: 'none',
overflowX: 'auto',
'&::-webkit-scrollbar': {
height: 10,
borderRadius: 4
},
'&::-webkit-scrollbar-track': {
bgcolor: 'rgba(0, 0, 0, 0.05)',
borderRadius: 4,
mx: 2
},
'&::-webkit-scrollbar-thumb': {
bgcolor: 'sage.400',
borderRadius: 4,
border: '2px solid',
borderColor: 'background.paper',
'&:hover': {
bgcolor: 'sage.500'
}
},
scrollBehavior: 'smooth'
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
<Box sx={{ width: '300%', height: '100%', position: 'relative' }}>
{/* Time Period Backgrounds */}
{timePeriods.map((period) => {
const startPos = ((period.start - actualMinYear) / timeSpan) * 100;
const width = ((period.end - period.start) / timeSpan) * 100;
return (
<Box
key={period.name}
sx={{
position: 'absolute',
left: `${startPos}%`,
width: `${width}%`,
height: '100%',
bgcolor: period.color,
opacity: 0.5
}}
/>
);
})}
{/* Year Markers */}
<Box sx={{
position: 'absolute',
top: 16,
left: 0,
right: 0,
height: 40,
display: 'flex',
zIndex: 2
}}>
{yearMarkers.map((year) => {
const isDecade = year % 10 === 0;
return (
<Box
key={year}
sx={{
position: 'absolute',
left: `${((year - actualMinYear) / timeSpan) * 100}%`,
transform: 'translateX(-50%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Box sx={{
width: isDecade ? 2 : 1,
height: isDecade ? 10 : 6,
bgcolor: isDecade ? 'primary.main' : 'text.secondary',
opacity: isDecade ? 0.8 : 0.5
}} />
<Typography
variant="caption"
sx={{
color: isDecade ? 'primary.main' : 'text.secondary',
mt: 0.5,
fontWeight: isDecade ? 600 : 400,
bgcolor: 'rgba(255, 255, 255, 0.9)',
px: 1,
py: 0.25,
borderRadius: 1,
fontSize: isDecade ? '0.75rem' : '0.7rem',
boxShadow: isDecade ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
border: isDecade ? '1px solid' : 'none',
borderColor: 'primary.100'
}}
>
{year}
</Typography>
</Box>
);
})}
</Box>
{/* Timeline Base Line */}
<Box
sx={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
height: 2,
bgcolor: 'grey.300',
transform: 'translateY(-50%)'
}}
/>
{/* Events */}
{filteredEvents.map((event, index) => {
const position = ((event.year - actualMinYear) / timeSpan) * 100;
const isTop = index % 2 === 0;
const colors = getEventColor(event.type);
const isSelected = selectedEvent?.year === event.year;
const verticalPosition = isTop ? 30 : 70;
return (
<Box
key={`${event.year}-${event.title}`}
onClick={() => handleEventClick(event)}
sx={{
position: 'absolute',
left: `${position}%`,
top: `${verticalPosition}%`,
transform: 'translate(-50%, -50%)',
zIndex: isSelected ? 2 : 1,
transition: 'all 0.3s ease',
cursor: 'pointer',
'&:hover': {
transform: 'translate(-50%, -50%) scale(1.1)'
}
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
height: 140
}}
>
{/* Top Connection Line */}
<Box
sx={{
position: 'absolute',
top: 0,
left: '50%',
transform: 'translateX(-50%)',
width: 2,
height: '30px',
bgcolor: colors.main,
opacity: 0.5
}}
/>
{/* Bottom Connection Line */}
<Box
sx={{
position: 'absolute',
bottom: 0,
left: '50%',
transform: 'translateX(-50%)',
width: 2,
height: '30px',
bgcolor: colors.main,
opacity: 0.5
}}
/>
{/* Event Dot */}
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: isSelected ? 16 : 12,
height: isSelected ? 16 : 12,
borderRadius: '50%',
bgcolor: colors.main,
transition: 'all 0.2s',
zIndex: 2,
'&::after': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '200%',
height: '200%',
borderRadius: '50%',
bgcolor: colors.light,
opacity: isSelected ? 1 : 0,
transition: 'all 0.2s'
}
}}
/>
{/* Event Label */}
<Paper
elevation={isSelected ? 3 : 1}
sx={{
position: 'absolute',
top: isTop ? 'auto' : '70%',
bottom: isTop ? '70%' : 'auto',
p: 1.5,
maxWidth: 180,
minWidth: 120,
bgcolor: isSelected ? colors.light : 'background.paper',
transform: isSelected ? 'scale(1.05)' : 'scale(1)',
transition: 'all 0.2s',
zIndex: isSelected ? 2 : 1,
mx: 2,
borderRadius: 2,
boxShadow: theme => isSelected
? theme.shadows[3]
: `0 2px 4px ${alpha(theme.palette.common.black, 0.05)}`,
'&:hover': {
boxShadow: theme => `0 4px 8px ${alpha(theme.palette.common.black, 0.1)}`
}
}}
>
<Typography
variant="caption"
sx={{
display: 'block',
textAlign: 'center',
fontWeight: isSelected ? 600 : 500,
color: isSelected ? 'text.primary' : 'text.secondary',
fontSize: '0.75rem',
lineHeight: 1.4,
whiteSpace: 'normal',
wordWrap: 'break-word'
}}
>
{event.title}
</Typography>
</Paper>
</Box>
</Box>
);
})}
{/* Draggable Indicator */}
<Box
sx={{
position: 'absolute',
left: `${dragPosition}%`,
top: 0,
bottom: 0,
width: 3,
bgcolor: 'text.primary',
transform: 'translateX(-50%)',
zIndex: 3,
'&::after': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: 'background.paper',
border: '3px solid',
borderColor: 'text.primary'
}
}}
>
<DragIndicator
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: 'text.primary',
fontSize: 16
}}
/>
</Box>
</Box>
</Box>
{/* Event Details Section */}
{selectedEvent && (
<Paper
sx={{
p: 4,
mt: 4,
bgcolor: 'background.paper',
borderRadius: 2,
boxShadow: 2,
transition: 'all 0.3s ease',
border: '1px solid',
borderColor: 'divider'
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box>
<Typography variant="h5" gutterBottom color="primary">
{selectedEvent.title}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{selectedEvent.year}
</Typography>
</Box>
<Chip
label={selectedEvent.type.charAt(0).toUpperCase() + selectedEvent.type.slice(1)}
color={
selectedEvent.type === 'works'
? 'primary'
: selectedEvent.type === 'context'
? 'secondary'
: selectedEvent.type === 'legacy'
? 'warning'
: 'success'
}
size="small"
/>
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="body1" paragraph>
{selectedEvent.description}
</Typography>
{selectedEvent.significance && (
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Historical Significance
</Typography>
<Typography variant="body2" color="text.secondary">
{selectedEvent.significance}
</Typography>
</Box>
)}
</Paper>
)}
</Box>
</Box>
);
}