jthorrigan/letterboxd-recommender
0
1"""2Visualization module for movie watching patterns3Creates interactive charts using Plotly4"""5import pandas as pd6import plotly.graph_objects as go7import plotly.express as px8from typing import Optional9from config import PLOT_HEIGHT, PLOT_TEMPLATE10 11 12class MovieVisualizer:13 """Create visualizations for movie watching patterns"""14 15 def __init__(self, ratings_df: pd.DataFrame):16 self.ratings_df = ratings_df17 18 def plot_rating_distribution(self) -> go.Figure:19 """Create histogram of rating distribution"""20 if 'rating' not in self.ratings_df.columns:21 return self._create_empty_plot("No rating data available")22 23 ratings = self.ratings_df['rating'].dropna()24 25 fig = go.Figure()26 fig.add_trace(go.Histogram(27 x=ratings,28 nbinsx=10,29 name='Ratings',30 marker_color='#00c030'31 ))32 33 fig.update_layout(34 title="Rating Distribution",35 xaxis_title="Rating (0.5 - 5.0)",36 yaxis_title="Number of Movies",37 height=PLOT_HEIGHT,38 template=PLOT_TEMPLATE,39 showlegend=False40 )41 42 return fig43 44 def plot_ratings_over_time(self) -> go.Figure:45 """Plot ratings over time"""46 if 'date' not in self.ratings_df.columns or 'rating' not in self.ratings_df.columns:47 return self._create_empty_plot("No date or rating data available")48 49 df = self.ratings_df[['date', 'rating']].dropna()50 51 if len(df) == 0:52 return self._create_empty_plot("No valid date/rating data")53 54 df = df.sort_values('date')55 56 # Calculate rolling average57 df['rolling_avg'] = df['rating'].rolling(window=10, min_periods=1).mean()58 59 fig = go.Figure()60 61 # Individual ratings62 fig.add_trace(go.Scatter(63 x=df['date'],64 y=df['rating'],65 mode='markers',66 name='Individual Ratings',67 marker=dict(size=6, color='#00c030', opacity=0.5)68 ))69 70 # Rolling average71 fig.add_trace(go.Scatter(72 x=df['date'],73 y=df['rolling_avg'],74 mode='lines',75 name='10-Movie Average',76 line=dict(color='#ff8000', width=3)77 ))78 79 fig.update_layout(80 title="Ratings Over Time",81 xaxis_title="Date",82 yaxis_title="Rating",83 height=PLOT_HEIGHT,84 template=PLOT_TEMPLATE,85 hovermode='closest'86 )87 88 return fig89 90 def plot_watch_frequency(self) -> go.Figure:91 """Plot number of movies watched over time"""92 if 'date' not in self.ratings_df.columns:93 return self._create_empty_plot("No date data available")94 95 df = self.ratings_df[['date']].dropna()96 97 if len(df) == 0:98 return self._create_empty_plot("No valid date data")99 100 # Group by month101 df['year_month'] = df['date'].dt.to_period('M')102 monthly_counts = df.groupby('year_month').size().reset_index(name='count')103 monthly_counts['date'] = monthly_counts['year_month'].dt.to_timestamp()104 105 fig = go.Figure()106 fig.add_trace(go.Bar(107 x=monthly_counts['date'],108 y=monthly_counts['count'],109 name='Movies Watched',110 marker_color='#00c030'111 ))112 113 fig.update_layout(114 title="Movies Watched Per Month",115 xaxis_title="Date",116 yaxis_title="Number of Movies",117 height=PLOT_HEIGHT,118 template=PLOT_TEMPLATE,119 showlegend=False120 )121 122 return fig123 124 def plot_year_distribution(self) -> go.Figure:125 """Plot distribution of movie years watched"""126 if 'year' not in self.ratings_df.columns:127 return self._create_empty_plot("No year data available")128 129 years = self.ratings_df['year'].dropna()130 131 if len(years) == 0:132 return self._create_empty_plot("No valid year data")133 134 # Group into decades135 decade_counts = years.apply(lambda x: (x // 10) * 10).value_counts().sort_index()136 137 fig = go.Figure()138 fig.add_trace(go.Bar(139 x=[f"{int(d)}s" for d in decade_counts.index],140 y=decade_counts.values,141 name='Movies',142 marker_color='#00c030'143 ))144 145 fig.update_layout(146 title="Movies Watched by Decade",147 xaxis_title="Decade",148 yaxis_title="Number of Movies",149 height=PLOT_HEIGHT,150 template=PLOT_TEMPLATE,151 showlegend=False152 )153 154 return fig155 156 def plot_rating_trends(self) -> go.Figure:157 """Analyze if user is getting harsher or more generous over time"""158 if 'date' not in self.ratings_df.columns or 'rating' not in self.ratings_df.columns:159 return self._create_empty_plot("No date or rating data available")160 161 df = self.ratings_df[['date', 'rating']].dropna()162 163 if len(df) < 10:164 return self._create_empty_plot("Need at least 10 ratings for trend analysis")165 166 df = df.sort_values('date')167 168 # Split into periods169 n = len(df)170 period_size = max(n // 5, 10) # At least 10 movies per period171 172 periods = []173 period_avgs = []174 175 for i in range(0, n, period_size):176 period_df = df.iloc[i:i+period_size]177 if len(period_df) > 0:178 periods.append(f"Period {len(periods)+1}")179 period_avgs.append(period_df['rating'].mean())180 181 fig = go.Figure()182 fig.add_trace(go.Scatter(183 x=periods,184 y=period_avgs,185 mode='lines+markers',186 name='Average Rating',187 line=dict(color='#00c030', width=3),188 marker=dict(size=10)189 ))190 191 # Add trend line192 if len(periods) > 1:193 import numpy as np194 x_numeric = list(range(len(periods)))195 z = np.polyfit(x_numeric, period_avgs, 1)196 p = np.poly1d(z)197 198 fig.add_trace(go.Scatter(199 x=periods,200 y=p(x_numeric),201 mode='lines',202 name='Trend',203 line=dict(color='#ff8000', width=2, dash='dash')204 ))205 206 fig.update_layout(207 title="Rating Trends Over Time",208 xaxis_title="Time Period",209 yaxis_title="Average Rating",210 height=PLOT_HEIGHT,211 template=PLOT_TEMPLATE212 )213 214 return fig215 216 def create_summary_stats(self) -> str:217 """Create summary statistics text"""218 if len(self.ratings_df) == 0:219 return "No data available"220 221 stats = f"## ๐ Summary Statistics\n\n"222 stats += f"**Total Movies Rated:** {len(self.ratings_df)}\n\n"223 224 if 'rating' in self.ratings_df.columns:225 ratings = self.ratings_df['rating'].dropna()226 if len(ratings) > 0:227 stats += f"**Average Rating:** {ratings.mean():.2f} / 5.0\n\n"228 stats += f"**Most Common Rating:** {ratings.mode().iloc[0]:.1f}\n\n"229 stats += f"**Rating Range:** {ratings.min():.1f} - {ratings.max():.1f}\n\n"230 231 if 'year' in self.ratings_df.columns:232 years = self.ratings_df['year'].dropna()233 if len(years) > 0:234 stats += f"**Year Range:** {int(years.min())} - {int(years.max())}\n\n"235 236 if 'date' in self.ratings_df.columns:237 dates = self.ratings_df['date'].dropna()238 if len(dates) > 0:239 stats += f"**Watching Period:** {dates.min().strftime('%Y-%m-%d')} to {dates.max().strftime('%Y-%m-%d')}\n\n"240 days = (dates.max() - dates.min()).days241 if days > 0:242 stats += f"**Movies per Day:** {len(self.ratings_df) / days:.2f}\n\n"243 244 return stats245 246 def _create_empty_plot(self, message: str) -> go.Figure:247 """Create empty plot with message"""248 fig = go.Figure()249 fig.add_annotation(250 text=message,251 xref="paper",252 yref="paper",253 x=0.5,254 y=0.5,255 showarrow=False,256 font=dict(size=16)257 )258 fig.update_layout(259 height=PLOT_HEIGHT,260 template=PLOT_TEMPLATE,261 xaxis=dict(showgrid=False, showticklabels=False),262 yaxis=dict(showgrid=False, showticklabels=False)263 )264 return fig