init
This commit is contained in:
BIN
src/__pycache__/constants.cpython-313.pyc
Normal file
BIN
src/__pycache__/constants.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/constants.cpython-314.pyc
Normal file
BIN
src/__pycache__/constants.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/player_table.cpython-313.pyc
Normal file
BIN
src/__pycache__/player_table.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/player_table.cpython-314.pyc
Normal file
BIN
src/__pycache__/player_table.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/stats.cpython-313.pyc
Normal file
BIN
src/__pycache__/stats.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/stats.cpython-314.pyc
Normal file
BIN
src/__pycache__/stats.cpython-314.pyc
Normal file
Binary file not shown.
257
src/app.py
Normal file
257
src/app.py
Normal file
@@ -0,0 +1,257 @@
|
||||
from flask import Flask, render_template
|
||||
import pandas as pd
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
import statsmodels.api as sm
|
||||
import numpy as np
|
||||
import datetime
|
||||
from sklearn.linear_model import LinearRegression
|
||||
import json
|
||||
from stats import generate_stats
|
||||
from player_table import generate_player_table
|
||||
import constants
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def get_data_frame(filename):
|
||||
df = pd.read_csv(filename)
|
||||
df["Date"] = pd.to_datetime(df["Date"], dayfirst=True)
|
||||
df = df.sort_values("Date").reset_index(drop=True)
|
||||
return df
|
||||
|
||||
|
||||
def build_hovertext(df, attendance_columns):
|
||||
return df[attendance_columns].apply(
|
||||
lambda row: ", ".join(
|
||||
[
|
||||
player
|
||||
for player in attendance_columns
|
||||
if row[player] == 1
|
||||
]
|
||||
) or "No attendance",
|
||||
axis=1
|
||||
)
|
||||
|
||||
|
||||
def generate_weekly_attendance_calendar(df):
|
||||
# Compute ISO year/week and attendance
|
||||
df["Year"] = df["Date"].dt.isocalendar().year
|
||||
df["Week"] = df["Date"].dt.isocalendar().week
|
||||
|
||||
attendee_columns = [
|
||||
col for col in df.columns if col not in {
|
||||
"Date", "Relative Position", "Number of Players",
|
||||
"Number of Teams", "Attendees", "Year", "Week", "Year-Week"
|
||||
}
|
||||
]
|
||||
|
||||
df["Attended"] = df[attendee_columns].sum(axis=1) > 0
|
||||
weekly_attendance = df.groupby(["Year", "Week"])[
|
||||
"Attended"].any().astype(int).reset_index()
|
||||
|
||||
# Build full year/week grid
|
||||
all_years = sorted(df["Year"].unique())
|
||||
all_weeks = list(range(1, 53))
|
||||
|
||||
grid = []
|
||||
for year in all_years:
|
||||
for week in all_weeks:
|
||||
grid.append({"Year": year, "Week": week})
|
||||
|
||||
calendar = pd.DataFrame(grid)
|
||||
calendar = calendar.merge(weekly_attendance, on=[
|
||||
"Year", "Week"], how="left").fillna(0)
|
||||
calendar["Attended"] = calendar["Attended"].astype(int)
|
||||
|
||||
# Plot
|
||||
fig = go.Figure(data=go.Heatmap(
|
||||
x=calendar["Week"],
|
||||
y=calendar["Year"],
|
||||
z=calendar["Attended"],
|
||||
colorscale=constants.ATTENDANCE_COLORSCHEME,
|
||||
zmin=0,
|
||||
zmax=1,
|
||||
showscale=False
|
||||
))
|
||||
|
||||
fig.update_layout(
|
||||
title="Pub Quiz Attendance Calendar (Weekly)",
|
||||
xaxis_title="Week Number",
|
||||
yaxis_title="Year",
|
||||
xaxis=dict(tickmode="linear", dtick=4),
|
||||
template="plotly_white",
|
||||
height=180 + len(all_years) * 40
|
||||
)
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def generate_relative_position_over_time(df):
|
||||
df["Date_ordinal"] = df["Date"].map(pd.Timestamp.toordinal)
|
||||
|
||||
X = sm.add_constant(df["Date_ordinal"])
|
||||
y = df["Relative Position"]
|
||||
|
||||
model = sm.OLS(y, X).fit()
|
||||
df["BestFit"] = model.predict(X)
|
||||
|
||||
intercept = model.params["const"]
|
||||
slope = model.params["Date_ordinal"]
|
||||
|
||||
target_value = 0.08
|
||||
|
||||
predicted_ordinal = (target_value - intercept) / slope
|
||||
|
||||
min_ordinal = df["Date_ordinal"].min()
|
||||
max_ordinal = df["Date_ordinal"].max()
|
||||
|
||||
if predicted_ordinal > max_ordinal:
|
||||
extended_ordinals = np.linspace(min_ordinal, predicted_ordinal, 100)
|
||||
else:
|
||||
extended_ordinals = np.linspace(min_ordinal, max_ordinal, 100)
|
||||
|
||||
extended_bestfit = intercept + slope * extended_ordinals
|
||||
|
||||
extended_dates = [datetime.date.fromordinal(
|
||||
int(x)) for x in extended_ordinals]
|
||||
|
||||
df["Attendees"] = build_hovertext(df, constants.PLAYER_NAME_COLUMNS)
|
||||
|
||||
fig = px.line(
|
||||
df,
|
||||
x="Date",
|
||||
y="Relative Position",
|
||||
title="Quiz Position Over Time with Extended Trendline",
|
||||
hover_data={"Attendees": True}
|
||||
)
|
||||
|
||||
fig.add_scatter(
|
||||
x=extended_dates,
|
||||
y=extended_bestfit,
|
||||
mode="lines",
|
||||
name="Extended Trendline",
|
||||
line=dict(dash="dot", color="red")
|
||||
)
|
||||
|
||||
fig.update_yaxes(range=[0, 1], tickformat=".2f")
|
||||
return fig
|
||||
|
||||
|
||||
def generate_visualisations(df):
|
||||
feature_columns = [
|
||||
col
|
||||
for col in df.columns
|
||||
if col in constants.FEATURE_COLUMNS
|
||||
]
|
||||
x = df[feature_columns]
|
||||
y = df["Relative Position"]
|
||||
|
||||
model = LinearRegression()
|
||||
model.fit(x, y)
|
||||
|
||||
plots = {}
|
||||
|
||||
plots["relative_pos_over_time"] = json.dumps(
|
||||
generate_relative_position_over_time(df),
|
||||
cls=plotly.utils.PlotlyJSONEncoder
|
||||
)
|
||||
|
||||
df_line = df.melt(
|
||||
id_vars="Date",
|
||||
value_vars=["Absolute Position", "Number of Teams"],
|
||||
var_name="Metric",
|
||||
value_name="Value"
|
||||
)
|
||||
fig11 = px.line(
|
||||
df_line,
|
||||
x='Date',
|
||||
y='Value',
|
||||
color='Metric',
|
||||
title='Absolute Position and Total Number of Teams Over Time'
|
||||
)
|
||||
plots["absolute_pos_over_time"] = json.dumps(
|
||||
fig11, cls=plotly.utils.PlotlyJSONEncoder
|
||||
)
|
||||
|
||||
# 2. Number of players vs position with regression line
|
||||
fig2 = px.scatter(
|
||||
df,
|
||||
x="Number of Players",
|
||||
y="Relative Position",
|
||||
trendline="ols",
|
||||
title="Players vs Position (%)",
|
||||
)
|
||||
fig2.update_xaxes(dtick=1)
|
||||
plots["players_vs_position"] = json.dumps(
|
||||
fig2, cls=plotly.utils.PlotlyJSONEncoder)
|
||||
|
||||
# 3. Player participation heatmap
|
||||
df_players = df[constants.PLAYER_NAME_COLUMNS]
|
||||
fig3 = px.imshow(
|
||||
df_players.T,
|
||||
labels=dict(x="Games", y="Player", color="Present"),
|
||||
title="Player Participation Heatmap",
|
||||
color_continuous_scale=constants.ATTENDANCE_COLORSCHEME,
|
||||
zmin=0,
|
||||
zmax=1,
|
||||
aspect="auto"
|
||||
)
|
||||
fig3.update_coloraxes(
|
||||
colorbar=dict(
|
||||
tickvals=[0, 1],
|
||||
ticktext=["Absent", "Present"],
|
||||
lenmode="pixels",
|
||||
len=300,
|
||||
)
|
||||
)
|
||||
fig3.update_layout(
|
||||
template="seaborn",
|
||||
height=600,
|
||||
yaxis=dict(
|
||||
tickmode="array",
|
||||
tickvals=list(range(len(df_players.columns))),
|
||||
ticktext=df_players.columns
|
||||
)
|
||||
)
|
||||
plots["player_participation"] = json.dumps(
|
||||
fig3, cls=plotly.utils.PlotlyJSONEncoder)
|
||||
|
||||
# 4. Calendar view
|
||||
plots["calendar"] = json.dumps(
|
||||
generate_weekly_attendance_calendar(df),
|
||||
cls=plotly.utils.PlotlyJSONEncoder
|
||||
)
|
||||
|
||||
# 5. Coefficient bar chart
|
||||
coefficients = pd.Series(model.coef_, index=x.columns).sort_values()
|
||||
fig5 = px.bar(
|
||||
coefficients,
|
||||
orientation="h",
|
||||
labels={"value": "Coefficient", "index": "Feature"},
|
||||
title="Linear Regression Coefficients",
|
||||
)
|
||||
plots["coefficients"] = json.dumps(
|
||||
fig5, cls=plotly.utils.PlotlyJSONEncoder)
|
||||
|
||||
return plots
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
df = get_data_frame("data.csv")
|
||||
stats = generate_stats(df)
|
||||
player_table = generate_player_table(df)
|
||||
plots = generate_visualisations(df)
|
||||
return render_template(
|
||||
"index.html",
|
||||
plots=plots,
|
||||
stats=stats,
|
||||
player_table=player_table
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import plotly
|
||||
|
||||
app.run(debug=True)
|
||||
32
src/constants.py
Normal file
32
src/constants.py
Normal file
@@ -0,0 +1,32 @@
|
||||
PLAYER_NAME_COLUMNS = [
|
||||
"Ciaran",
|
||||
"Jay",
|
||||
"Sam",
|
||||
"Drew",
|
||||
"Theo",
|
||||
"Tom",
|
||||
"Ellora",
|
||||
"Chloe",
|
||||
"Jamie",
|
||||
"Christine"
|
||||
]
|
||||
|
||||
FEATURE_COLUMNS = {
|
||||
"Number of Players",
|
||||
"Number of Teams",
|
||||
"Points on Scattergories",
|
||||
"Ciaran",
|
||||
"Jay",
|
||||
"Sam",
|
||||
"Drew",
|
||||
"Theo",
|
||||
"Tom",
|
||||
"Ellora",
|
||||
"Chloe",
|
||||
"Jamie",
|
||||
"Christine"
|
||||
}
|
||||
|
||||
ATTENDANCE_COLORSCHEME = [
|
||||
[0, "#E4ECF6"], [1, "#636EFA"]
|
||||
]
|
||||
20
src/player_table.py
Normal file
20
src/player_table.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from constants import PLAYER_NAME_COLUMNS
|
||||
|
||||
|
||||
def generate_player_table(df):
|
||||
header = [["Name", "Appearances", "Spent"]]
|
||||
|
||||
player_stats = [
|
||||
(name, df[name].sum())
|
||||
for name in df.columns
|
||||
if name in PLAYER_NAME_COLUMNS
|
||||
]
|
||||
player_stats.sort(key=lambda x: x[1], reverse=True)
|
||||
total = sum(n for name, n in player_stats)
|
||||
body = [
|
||||
[name, n, f"£{n * 3:.2f}"]
|
||||
for name, n in player_stats
|
||||
]
|
||||
footer = [["Total", total, f"£{total * 3:.2f}"]]
|
||||
|
||||
return (header + body + footer)
|
||||
53
src/stats.py
Normal file
53
src/stats.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import constants
|
||||
|
||||
|
||||
def get_max_team_streak(dates):
|
||||
max_streak = current_streak = 1
|
||||
for i in range(1, len(dates)):
|
||||
if (dates[i] - dates[i-1]).days == 7:
|
||||
current_streak += 1
|
||||
else:
|
||||
current_streak = 1
|
||||
max_streak = max(current_streak, max_streak)
|
||||
|
||||
# TODO: Show dates
|
||||
return f"{max_streak} weeks"
|
||||
|
||||
|
||||
def get_max_player_streak(df):
|
||||
names = [col for col in df.columns if col in constants.PLAYER_NAME_COLUMNS]
|
||||
max_streak = 1
|
||||
max_name = names[0]
|
||||
for name in names:
|
||||
local_max = 1
|
||||
current = 0
|
||||
for attendance in df[name]:
|
||||
if attendance:
|
||||
current += 1
|
||||
else:
|
||||
local_max = max(local_max, current)
|
||||
current = 0
|
||||
|
||||
local_max = max(local_max, current)
|
||||
if local_max > max_streak:
|
||||
max_streak = local_max
|
||||
max_name = name
|
||||
|
||||
return f"{max_streak} ({max_name})"
|
||||
|
||||
|
||||
def generate_stats(df):
|
||||
stats = {}
|
||||
n = len(df["Date"])
|
||||
|
||||
stats["Number of quizes played"] = n
|
||||
avg_players = df["Number of Players"].sum() / n
|
||||
stats["Average number of players"] = f"{avg_players:.2f}"
|
||||
avg_teams = df["Number of Teams"].sum() / n
|
||||
stats["Average number of teams"] = f"{avg_teams:.2f}"
|
||||
p = df["Relative Position"].sum()*100 / n
|
||||
stats["Average relative position"] = f"{p:.2f}th percentile"
|
||||
stats["Max consecutive week streak"] = get_max_team_streak(df["Date"])
|
||||
stats["Max consecutive player streak"] = get_max_player_streak(df)
|
||||
|
||||
return stats
|
||||
80
src/templates/index.html
Normal file
80
src/templates/index.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Pub Quiz Dashboard</title>
|
||||
<script src="https://cdn.plot.ly/plotly-3.0.1.min.js" charset="utf-8"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
|
||||
<body class="flex flex-col items-center">
|
||||
<h1 class="text-center font-sans text-4xl p-10">📊 Pub Quiz Dashboard</h1>
|
||||
|
||||
<div class="w-3/4">
|
||||
<h2 class="text-2xl mb-4">Stats</h2>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="rounded-md border border-black w-2/5 p-4 m-8">
|
||||
<ul class="space-y-2">
|
||||
{% for key, data in stats.items() %}
|
||||
<li id="{{ key }}" class="flex justify-between">
|
||||
<span>{{ key }}:</span>
|
||||
<span class="flex-grow border-b border-dotted mx-2"></span>
|
||||
<span>{{ data }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-3/4 flex flex-col items-center overflow-x-auto">
|
||||
<table class="w-1/2 text-sm text-gray-700 border-separate border-spacing-0 rounded-xl bg-clip-border shadow-md">
|
||||
<thead
|
||||
class="bg-gray-100 text-xs font-semibold uppercase tracking-wider text-gray-700 border-b border-gray-300">
|
||||
<tr>
|
||||
{% for heading in player_table[0] %}
|
||||
<th scope="col" class="px-6 py-4 w-1/3 border-b border-blue-grey-100 bg-blue-grey-50
|
||||
{% if loop.index == 1 %} text-left
|
||||
{% elif loop.index == 2 %} text-center
|
||||
{% else %} text-right
|
||||
{% endif %}
|
||||
">
|
||||
{{ heading }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{% for row in player_table[1:] %}
|
||||
<tr class="bg-white">
|
||||
{% for data in row %}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 antialiased leading-normal border-b border-blue-grey-50
|
||||
{% if loop.index == 1 %} text-left
|
||||
{% elif loop.index == 2 %} text-center
|
||||
{% else %} text-right
|
||||
{% endif %}">
|
||||
{{ data }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="w-3/4">
|
||||
<h2 class="text-2xl">Plots</h2>
|
||||
{% for key, plot in plots.items() %}
|
||||
<div class="m-8 bg-white p-8 rounded-md shadow-md">
|
||||
<div id="{{ key }}"></div>
|
||||
<script>
|
||||
var figure = {{plot | safe }};
|
||||
Plotly.newPlot("{{ key }}", figure.data, figure.layout);
|
||||
</script>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user