Replace the "Area N" fallbacks with derived names: Kiddieland (lowest mean PC2) plus The Woodies / The Grand Midway / Flying Turns Grove / Giant Wheel Corner, each keyed off the headline ride that lands in the cluster so the labels survive re-clustering as the wait history grows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
586 lines
25 KiB
Python
586 lines
25 KiB
Python
# Knoebels Queue Dashboard v1.1 - Scaled wait times fix
|
|
import streamlit as st
|
|
import pandas as pd
|
|
import numpy as np
|
|
import psycopg2
|
|
from psycopg2.extras import RealDictCursor
|
|
import plotly.express as px
|
|
import plotly.graph_objects as go
|
|
import os
|
|
from datetime import datetime, timedelta
|
|
from dotenv import load_dotenv
|
|
|
|
# Load environment variables from .env if it exists
|
|
load_dotenv(os.path.join(os.path.dirname(__file__), '.env'))
|
|
|
|
# --- Configuration ---
|
|
st.set_page_config(
|
|
page_title="Knoebels Queue Dashboard",
|
|
page_icon="🎢",
|
|
layout="wide",
|
|
)
|
|
|
|
# --- Database Connection ---
|
|
def get_db_connection():
|
|
try:
|
|
conn = psycopg2.connect(
|
|
host=os.environ.get('DB_HOST', '192.168.88.9'),
|
|
port=os.environ.get('DB_PORT', '5432'),
|
|
database=os.environ.get('DB_NAME', 'gp0'),
|
|
user=os.environ.get('DB_USER', 'kuhnobowls'),
|
|
password=os.environ.get('DB_PASSWORD'),
|
|
options="-c search_path=knoebels"
|
|
)
|
|
return conn
|
|
except Exception as e:
|
|
st.error(f"Database connection failed: {e}")
|
|
return None
|
|
|
|
@st.cache_data(ttl=300)
|
|
def fetch_latest_status():
|
|
conn = get_db_connection()
|
|
if not conn: return pd.DataFrame()
|
|
query = "SELECT * FROM dm_latest_ride_info"
|
|
df = pd.read_sql(query, conn)
|
|
conn.close()
|
|
if not df.empty:
|
|
# Safely convert and scale wait times to minutes
|
|
df['queue_time'] = pd.to_numeric(df['queue_time'], errors='coerce')
|
|
df['queue_time'] = (df['queue_time'] / 60.0).round(0)
|
|
# Ensure coordinates are numeric
|
|
df['lattitude'] = pd.to_numeric(df['lattitude'], errors='coerce')
|
|
df['longitude'] = pd.to_numeric(df['longitude'], errors='coerce')
|
|
return df
|
|
|
|
@st.cache_data(ttl=600)
|
|
def fetch_historical_trends(days=2):
|
|
conn = get_db_connection()
|
|
if not conn: return pd.DataFrame()
|
|
query = f"""
|
|
SELECT time_stamp, name, queue_time, is_operational, category
|
|
FROM dm_knb_live_data
|
|
WHERE time_stamp > NOW() - INTERVAL '{days} days'
|
|
ORDER BY time_stamp ASC
|
|
"""
|
|
df = pd.read_sql(query, conn)
|
|
conn.close()
|
|
if not df.empty:
|
|
# Safely convert and scale wait times to minutes
|
|
df['queue_time'] = pd.to_numeric(df['queue_time'], errors='coerce')
|
|
df['queue_time'] = (df['queue_time'] / 60.0).round(1)
|
|
return df
|
|
|
|
@st.cache_data(ttl=3600)
|
|
def fetch_categories():
|
|
conn = get_db_connection()
|
|
if not conn: return {}
|
|
query = 'SELECT _id, name FROM "LZ_attractions_io_categories"'
|
|
with conn.cursor() as cur:
|
|
cur.execute(query)
|
|
cats = {row[0]: row[1] for row in cur.fetchall()}
|
|
conn.close()
|
|
return cats
|
|
|
|
# --- PCA: "reading the park's mind" ----------------------------------------
|
|
# Reproduces the analysis from the blog post (Reading a Theme Park's Mind with
|
|
# PCA). Standardize the time x ride wait matrix, take the top 3 components, and
|
|
# read them as: PC1 = overall busyness, PC2 = Kiddieland vs. main midway
|
|
# (spatial), PC3 = daytime school-group rides vs. the evening crowd.
|
|
|
|
PC_LABELS = {
|
|
"PC1": "Overall busyness",
|
|
"PC2": "Kiddieland ↔ main midway",
|
|
"PC3": "Daytime crowd ↔ evening crowd",
|
|
}
|
|
PC_BLURB = {
|
|
"PC1": "How many people are in the park. Every ride loads the same sign — "
|
|
"when it's packed, everything spikes together.",
|
|
"PC2": "Which half of the park they're in. A contrast: Kiddieland corner "
|
|
"(negative) vs. the main midway (positive). It's geography, not the "
|
|
"park's own category tags.",
|
|
"PC3": "What time of day it is. Daytime family flat-rides the school/daycare "
|
|
"groups hit at open (positive) vs. the evening coaster & date-night "
|
|
"crowd (negative).",
|
|
}
|
|
CATEGORY_COLORS = {"Family": "#4C9BE8", "Kiddie": "#E8A33D", "Thrill": "#E0524E"}
|
|
|
|
|
|
@st.cache_data(ttl=21600) # 6h — underlying history grows slowly, PCA is heavy
|
|
def compute_pca():
|
|
"""Pull the full wait history, build the standardized time x ride matrix,
|
|
and return (loadings, scores, explained_variance_ratio, ride_meta).
|
|
|
|
ride_meta carries each ride's category tag (trailing space trimmed) and GPS
|
|
coordinates, so the same DataFrame drives the behavioral map."""
|
|
from sklearn.preprocessing import StandardScaler
|
|
from sklearn.decomposition import PCA
|
|
from sklearn.cluster import KMeans
|
|
|
|
conn = get_db_connection()
|
|
if not conn:
|
|
return None
|
|
|
|
waits = pd.read_sql(
|
|
"""
|
|
SELECT a.time_stamp, r.ride AS ride_name, a.queue_time AS queue_time_sec
|
|
FROM knoebels."LZ_attractions_io" a
|
|
JOIN knoebels.ride_master r ON r.attractions_dot_io_id = a._id
|
|
WHERE a.queue_time IS NOT NULL
|
|
""",
|
|
conn,
|
|
)
|
|
meta = pd.read_sql(
|
|
"""
|
|
SELECT r.ride AS ride_name,
|
|
trim(c.name) AS category,
|
|
(p.location)[0]::float AS lat,
|
|
(p.location)[1]::float AS lon
|
|
FROM knoebels.ride_master r
|
|
JOIN knoebels."LZ_attractions_io_poi" p ON p._id = r.attractions_dot_io_id
|
|
LEFT JOIN knoebels."LZ_attractions_io_categories" c ON c._id = p.category
|
|
""",
|
|
conn,
|
|
).set_index("ride_name")
|
|
conn.close()
|
|
|
|
if waits.empty:
|
|
return None
|
|
|
|
waits["time_stamp"] = pd.to_datetime(waits["time_stamp"])
|
|
waits["queue_time_min"] = pd.to_numeric(waits["queue_time_sec"], errors="coerce") / 60.0
|
|
|
|
# long -> (time x ride) matrix: mean wait per 5-min bin, ffill then zero
|
|
matrix = (
|
|
waits.assign(ts=waits["time_stamp"].dt.floor("5min"))
|
|
.pivot_table(index="ts", columns="ride_name",
|
|
values="queue_time_min", aggfunc="mean")
|
|
.sort_index()
|
|
.ffill()
|
|
.fillna(0)
|
|
.drop(columns=["powersurge"], errors="ignore") # ~90% imputed, noise
|
|
)
|
|
|
|
scaled = StandardScaler().fit_transform(matrix) # each ride mean 0, var 1
|
|
pca = PCA(n_components=3)
|
|
sc = pca.fit_transform(scaled)
|
|
|
|
scores = pd.DataFrame(sc, index=matrix.index, columns=["PC1", "PC2", "PC3"])
|
|
loadings = pd.DataFrame(pca.components_.T, index=matrix.columns,
|
|
columns=["PC1", "PC2", "PC3"])
|
|
loadings = loadings.join(meta)
|
|
|
|
# Spatial "areas" from GPS alone — k-means on standardized lat/lon, the same
|
|
# 5 clusters the write-up uses. Names are derived, not hard-coded to a
|
|
# cluster index, so they survive re-clustering as the history grows:
|
|
# - Kiddieland = the cluster whose rides lean most negative on PC2.
|
|
# - the rest are named for the marquee ride that lands in each.
|
|
located = loadings.dropna(subset=["lat", "lon"])
|
|
if len(located) >= 5:
|
|
km = KMeans(n_clusters=5, n_init=10, random_state=0).fit_predict(
|
|
StandardScaler().fit_transform(located[["lat", "lon"]]))
|
|
area = pd.Series(km, index=located.index)
|
|
kiddie = loadings.loc[area.index].assign(a=area).groupby("a")["PC2"].mean().idxmin()
|
|
names = {kiddie: "Kiddieland"}
|
|
anchors = [("phoenix", "The Woodies"),
|
|
("impulse", "The Grand Midway"),
|
|
("flying_turns", "Flying Turns Grove"),
|
|
("giant_wheel", "Giant Wheel Corner")]
|
|
for ride, label in anchors:
|
|
if ride in area.index:
|
|
names.setdefault(area[ride], label) # don't overwrite Kiddieland
|
|
loadings["area"] = area.map(lambda a: names.get(a, f"Area {a + 1}"))
|
|
else:
|
|
loadings["area"] = "Untagged"
|
|
|
|
return {
|
|
"loadings": loadings,
|
|
"scores": scores,
|
|
"evr": pca.explained_variance_ratio_,
|
|
}
|
|
|
|
# --- Header ---
|
|
st.title("🎢 Knoebels Queue Time Dashboard")
|
|
st.markdown("---")
|
|
|
|
# --- Sidebar Filters ---
|
|
st.sidebar.header("Dashboard Filters")
|
|
latest_df = fetch_latest_status()
|
|
categories = fetch_categories()
|
|
|
|
if not latest_df.empty:
|
|
all_cats = sorted(latest_df['category'].dropna().unique())
|
|
cat_options = {c: categories.get(c, f"Category {c}") for c in all_cats}
|
|
selected_cats = st.sidebar.multiselect(
|
|
"Filter by Category",
|
|
options=all_cats,
|
|
format_func=lambda x: cat_options.get(x),
|
|
default=all_cats
|
|
)
|
|
|
|
filtered_latest = latest_df[latest_df['category'].isin(selected_cats)]
|
|
else:
|
|
st.warning("No data found in the database. Please check your connection and .env file.")
|
|
st.stop()
|
|
|
|
# --- Tabs ---
|
|
tab1, tab2, tab3, tab4 = st.tabs(
|
|
["📍 Live Status", "📈 Historical Trends", "🔍 Ride Explorer", "🧠 Park Mind (PCA)"]
|
|
)
|
|
|
|
with tab1:
|
|
# KPI Row
|
|
col1, col2, col3, col4 = st.columns(4)
|
|
open_rides = filtered_latest[filtered_latest['is_open'] == True]
|
|
avg_wait = open_rides['queue_time'].mean()
|
|
max_wait_row = open_rides.loc[open_rides['queue_time'].idxmax()] if not open_rides.empty else None
|
|
|
|
col1.metric("Rides Open", len(open_rides))
|
|
col2.metric("Avg Wait Time", f"{int(avg_wait)} min" if not pd.isna(avg_wait) else "N/A")
|
|
if max_wait_row is not None:
|
|
col3.metric("Longest Queue", f"{int(max_wait_row['queue_time'])} min", f"on {max_wait_row['name']}")
|
|
else:
|
|
col3.metric("Longest Queue", "N/A")
|
|
col4.metric("Operational %", f"{int(len(open_rides)/len(filtered_latest)*100)}%" if len(filtered_latest) > 0 else "0%")
|
|
|
|
# Map and Table
|
|
mcol1, mcol2 = st.columns([2, 1])
|
|
|
|
with mcol1:
|
|
st.subheader("Park Map")
|
|
|
|
# Prepare data for map: drop rows without valid coordinates
|
|
map_df = filtered_latest.dropna(subset=['lattitude', 'longitude']).copy()
|
|
|
|
if not map_df.empty:
|
|
# Handle NaN wait times for sizing: fill with 0 and set minimum size
|
|
map_df['wait_size'] = map_df['queue_time'].fillna(0).apply(lambda x: max(float(x), 5.0))
|
|
|
|
# Plotly map for better control than st.map
|
|
fig_map = px.scatter_mapbox(
|
|
map_df,
|
|
lat="lattitude",
|
|
lon="longitude",
|
|
color="queue_time",
|
|
size="wait_size",
|
|
hover_name="name",
|
|
hover_data=["queue_status_message", "price"],
|
|
color_continuous_scale="Viridis",
|
|
zoom=15,
|
|
height=600,
|
|
mapbox_style="carto-darkmatter"
|
|
)
|
|
fig_map.update_layout(margin={"r":0,"t":0,"l":0,"b":0})
|
|
st.plotly_chart(fig_map, use_container_width=True)
|
|
else:
|
|
st.info("No location data available to display map.")
|
|
|
|
with mcol2:
|
|
st.subheader("Live Board")
|
|
board_df = filtered_latest[['name', 'queue_time', 'is_open', 'price']].sort_values('queue_time', ascending=False)
|
|
st.dataframe(
|
|
board_df,
|
|
column_config={
|
|
"name": "Ride",
|
|
"queue_time": st.column_config.NumberColumn("Wait (min)", format="%d ⏱️"),
|
|
"is_open": "Status",
|
|
"price": st.column_config.NumberColumn("Price", format="$%.2f")
|
|
},
|
|
hide_index=True,
|
|
use_container_width=True
|
|
)
|
|
|
|
with tab2:
|
|
st.subheader("Wait Time Trends (Last 48 Hours)")
|
|
hist_df = fetch_historical_trends(days=2)
|
|
|
|
if not hist_df.empty:
|
|
# Filter historical data by selected categories
|
|
hist_df = hist_df[hist_df['category'].isin(selected_cats)]
|
|
|
|
# Aggregate by timestamp
|
|
avg_hist = hist_df.groupby('time_stamp')['queue_time'].mean().reset_index()
|
|
|
|
fig_trend = px.line(
|
|
avg_hist,
|
|
x='time_stamp',
|
|
y='queue_time',
|
|
title="Park-wide Average Wait Time",
|
|
labels={'queue_time': 'Avg Wait (min)', 'time_stamp': 'Time'}
|
|
)
|
|
fig_trend.update_traces(line_color="#2E8B57")
|
|
st.plotly_chart(fig_trend, use_container_width=True)
|
|
|
|
# Heatmap
|
|
st.subheader("Busiest Times of Day")
|
|
hist_df['hour'] = pd.to_datetime(hist_df['time_stamp']).dt.hour
|
|
hist_df['day'] = pd.to_datetime(hist_df['time_stamp']).dt.day_name()
|
|
|
|
heatmap_data = hist_df.groupby(['day', 'hour'])['queue_time'].mean().unstack().fillna(0)
|
|
# Order days
|
|
days_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
|
heatmap_data = heatmap_data.reindex(days_order)
|
|
|
|
fig_heat = px.imshow(
|
|
heatmap_data,
|
|
labels=dict(x="Hour of Day", y="Day of Week", color="Avg Wait (min)"),
|
|
x=heatmap_data.columns,
|
|
y=heatmap_data.index,
|
|
color_continuous_scale="YlGn"
|
|
)
|
|
st.plotly_chart(fig_heat, use_container_width=True)
|
|
else:
|
|
st.info("No historical data available for the selected period.")
|
|
|
|
with tab3:
|
|
st.subheader("Ride Explorer")
|
|
ride_names = sorted(latest_df['name'].unique())
|
|
selected_ride = st.selectbox("Select a Ride to Explore", options=ride_names)
|
|
|
|
ride_info = latest_df[latest_df['name'] == selected_ride].iloc[0]
|
|
|
|
ecol1, ecol2 = st.columns([1, 2])
|
|
|
|
with ecol1:
|
|
st.markdown(f"### {ride_info['name']}")
|
|
st.write(f"**Price:** ${ride_info['price']:.2f}")
|
|
st.write(f"**Capacity:** {ride_info['capacity']} pph")
|
|
st.write(f"**Duration:** {ride_info['duration']} sec")
|
|
st.write(f"**Handstamp Included:** {'Yes' if ride_info['hand_stamp_included'] else 'No'}")
|
|
st.write(f"**Height Requirement:** {ride_info['minimum_height_requirement']}\"")
|
|
st.markdown("---")
|
|
st.write(f"**Summary:** {ride_info['summary']}")
|
|
if ride_info['restriction_summary']:
|
|
st.warning(f"**Restrictions:** {ride_info['restriction_summary']}")
|
|
|
|
with ecol2:
|
|
st.subheader(f"7-Day History: {selected_ride}")
|
|
# Fetch longer history for specific ride
|
|
conn = get_db_connection()
|
|
ride_id = ride_info['_id']
|
|
ride_hist_query = f"SELECT time_stamp, queue_time FROM dm_knb_live_data WHERE _id = {ride_id} AND time_stamp > NOW() - INTERVAL '7 days' ORDER BY time_stamp ASC"
|
|
ride_hist_df = pd.read_sql(ride_hist_query, conn)
|
|
conn.close()
|
|
|
|
if not ride_hist_df.empty:
|
|
# Convert to minutes
|
|
ride_hist_df['queue_time'] = pd.to_numeric(ride_hist_df['queue_time'], errors='coerce')
|
|
ride_hist_df['queue_time'] = (ride_hist_df['queue_time'] / 60.0).round(1)
|
|
|
|
fig_ride_hist = px.area(
|
|
ride_hist_df,
|
|
x='time_stamp',
|
|
y='queue_time',
|
|
labels={'queue_time': 'Wait Time (min)', 'time_stamp': 'Time'}
|
|
)
|
|
fig_ride_hist.update_traces(line_color="#2E8B57", fillcolor="rgba(46, 139, 87, 0.3)")
|
|
st.plotly_chart(fig_ride_hist, use_container_width=True)
|
|
else:
|
|
st.info("No historical data found for this ride.")
|
|
|
|
with tab4:
|
|
st.subheader("🧠 What two seasons of wait times reveal about the park")
|
|
st.markdown(
|
|
"Feed every ride's wait-time history into **Principal Component Analysis** "
|
|
"— with *no* labels for what any ride is or where it sits — and three "
|
|
"hidden axes fall out on their own. They turn out to be **how busy the "
|
|
"park is**, **which half of it you're in**, and **what time of day it is**. "
|
|
"[Full write-up here.](https://blog.c0smere.net/reading-a-theme-parks-mindwith-pca/)"
|
|
)
|
|
|
|
try:
|
|
pca = compute_pca()
|
|
except ImportError:
|
|
st.error("`scikit-learn` isn't installed in this image yet — rebuild the "
|
|
"dashboard container to enable the PCA tab.")
|
|
pca = None
|
|
|
|
if pca is None:
|
|
st.info("Not enough data to compute the components right now.")
|
|
else:
|
|
loadings, scores, evr = pca["loadings"], pca["scores"], pca["evr"]
|
|
|
|
# --- the three axes, with their explained variance ---
|
|
st.markdown("#### The three axes PCA found")
|
|
cols = st.columns(3)
|
|
for col, pc in zip(cols, ["PC1", "PC2", "PC3"]):
|
|
col.metric(f"{pc} · {PC_LABELS[pc]}", f"{evr[int(pc[-1]) - 1]:.1%}",
|
|
help=PC_BLURB[pc])
|
|
st.caption(f"Just these 3 axes (out of {loadings.shape[0]}) capture "
|
|
f"**{evr.sum():.1%}** of all the variation in park wait times.")
|
|
|
|
st.markdown("---")
|
|
|
|
# --- the behavioral map: PC2 vs PC3, colored by the park's own tag ---
|
|
st.markdown("#### The map of the park, drawn from wait times alone")
|
|
st.markdown(
|
|
"Each ride placed by its **PC2** (left↔right = Kiddieland↔midway) and "
|
|
"**PC3** (bottom↔top = evening↔daytime) loading — its *behavioral* "
|
|
"position — and colored by the park's category tag. The position comes "
|
|
"purely from how each queue moves over time, yet **the picture is the "
|
|
"park**: Kiddieland huddles left, evening thrill rides sink bottom-right, "
|
|
"daytime family rides float to the top."
|
|
)
|
|
plot_df = loadings.reset_index().copy()
|
|
plot_df["category"] = plot_df["category"].fillna("Untagged")
|
|
fig_map_pca = px.scatter(
|
|
plot_df, x="PC2", y="PC3", color="category", text="ride_name",
|
|
color_discrete_map=CATEGORY_COLORS,
|
|
labels={"PC2": "PC2 · Kiddieland ←→ main midway",
|
|
"PC3": "PC3 · evening crowd ←→ daytime crowd",
|
|
"category": "Park's tag"},
|
|
hover_name="ride_name", height=620,
|
|
)
|
|
fig_map_pca.update_traces(textposition="top center",
|
|
textfont=dict(size=9, color="#9aa0a6"),
|
|
marker=dict(size=11, line=dict(width=1, color="#1A1C18")))
|
|
fig_map_pca.add_hline(y=0, line_width=0.5, line_color="grey")
|
|
fig_map_pca.add_vline(x=0, line_width=0.5, line_color="grey")
|
|
fig_map_pca.update_layout(legend=dict(orientation="h", yanchor="bottom", y=1.02))
|
|
st.plotly_chart(fig_map_pca, use_container_width=True)
|
|
|
|
st.markdown("---")
|
|
|
|
# --- the same idea in 3D: add PC1 as a third axis -------------------
|
|
st.markdown("#### The full 3-D picture")
|
|
st.markdown(
|
|
"The map above flattens out **PC1** (overall busyness) to fit on a "
|
|
"page. Here are both clouds in their native 3-D — click-drag to spin, "
|
|
"scroll to zoom. Two different things get plotted:"
|
|
)
|
|
|
|
rides_tab, moments_tab = st.tabs(
|
|
["🎢 Rides in PC-space", "⏱️ Moments in PC-space"])
|
|
|
|
# ---- A) each RIDE as a point (the loadings) ----
|
|
with rides_tab:
|
|
st.caption(
|
|
"Every **ride** placed by how its queue behaves. Notice the cloud "
|
|
"is nearly flat along **PC1** — almost every ride loads the same "
|
|
"way on overall-busyness, so it barely separates them. All the "
|
|
"real structure lives in the PC2/PC3 plane you saw above.")
|
|
ride_color = st.selectbox(
|
|
"Color the rides by",
|
|
["Park category tag", "Spatial area (GPS k-means)",
|
|
"Overall busyness (PC1 loading)"],
|
|
key="ride_color3d")
|
|
rdf = loadings.reset_index().copy()
|
|
rdf["category"] = rdf["category"].fillna("Untagged")
|
|
common = dict(x="PC1", y="PC2", z="PC3", hover_name="ride_name", height=640)
|
|
if ride_color == "Park category tag":
|
|
fig3d_r = px.scatter_3d(rdf, color="category",
|
|
color_discrete_map={**CATEGORY_COLORS, "Untagged": "#888"}, **common)
|
|
elif ride_color == "Spatial area (GPS k-means)":
|
|
fig3d_r = px.scatter_3d(rdf, color="area", **common)
|
|
else:
|
|
fig3d_r = px.scatter_3d(rdf, color="PC1",
|
|
color_continuous_scale="Viridis", **common)
|
|
fig3d_r.update_traces(marker=dict(size=5, line=dict(width=0.5, color="#1A1C18")))
|
|
fig3d_r.update_layout(
|
|
margin=dict(l=0, r=0, t=0, b=0),
|
|
scene=dict(xaxis_title="PC1 · busyness",
|
|
yaxis_title="PC2 · Kiddieland↔midway",
|
|
zaxis_title="PC3 · evening↔daytime"))
|
|
st.plotly_chart(fig3d_r, use_container_width=True)
|
|
|
|
# ---- B) each MOMENT as a point (the scores) ----
|
|
with moments_tab:
|
|
st.caption(
|
|
"Every **5-minute snapshot** of the park as one point — this is the "
|
|
"direct analog of an embedding cloud, but each dot is a *moment*, "
|
|
"not a note. Unlike the rides, the moments spread on all three "
|
|
"axes: a packed evening sits far out on PC1, a kiddie-heavy morning "
|
|
"pulls toward the PC2/PC3 corners.")
|
|
mom_color = st.selectbox(
|
|
"Color the moments by",
|
|
["Hour of day", "Day of week", "Weekend vs. weekday", "Month"],
|
|
key="mom_color3d")
|
|
sdf = scores.copy()
|
|
idx = pd.DatetimeIndex(sdf.index)
|
|
sdf["Hour of day"] = idx.hour
|
|
sdf["Day of week"] = idx.day_name()
|
|
sdf["Weekend vs. weekday"] = np.where(idx.weekday >= 5, "Weekend", "Weekday")
|
|
sdf["Month"] = idx.month_name()
|
|
# keep it responsive — sample if the history is large
|
|
cap = 15000
|
|
note = ""
|
|
if len(sdf) > cap:
|
|
sdf = sdf.sample(cap, random_state=0)
|
|
note = f" *(showing a random {cap:,} of {len(scores):,} snapshots)*"
|
|
common_m = dict(x="PC1", y="PC2", z="PC3", height=640, opacity=0.55)
|
|
if mom_color == "Hour of day":
|
|
fig3d_m = px.scatter_3d(sdf, color="Hour of day",
|
|
color_continuous_scale="Turbo", **common_m)
|
|
elif mom_color == "Month":
|
|
order = [pd.Timestamp(2020, m, 1).month_name() for m in range(1, 13)]
|
|
fig3d_m = px.scatter_3d(sdf, color="Month",
|
|
category_orders={"Month": order}, **common_m)
|
|
elif mom_color == "Day of week":
|
|
order = ["Monday", "Tuesday", "Wednesday", "Thursday",
|
|
"Friday", "Saturday", "Sunday"]
|
|
fig3d_m = px.scatter_3d(sdf, color="Day of week",
|
|
category_orders={"Day of week": order}, **common_m)
|
|
else:
|
|
fig3d_m = px.scatter_3d(sdf, color="Weekend vs. weekday",
|
|
color_discrete_map={"Weekday": "#4C9BE8", "Weekend": "#E0524E"},
|
|
**common_m)
|
|
fig3d_m.update_traces(marker=dict(size=2.5))
|
|
fig3d_m.update_layout(
|
|
margin=dict(l=0, r=0, t=0, b=0),
|
|
scene=dict(xaxis_title="PC1 · busyness",
|
|
yaxis_title="PC2 · Kiddieland↔midway",
|
|
zaxis_title="PC3 · evening↔daytime"))
|
|
st.plotly_chart(fig3d_m, use_container_width=True)
|
|
if note:
|
|
st.caption(note.strip(" *"))
|
|
|
|
st.markdown("---")
|
|
|
|
# --- loadings explorer: which rides drive a chosen component ---
|
|
lcol, rcol = st.columns([1, 2])
|
|
with lcol:
|
|
st.markdown("#### Read an axis")
|
|
pick = st.radio("Component", ["PC1", "PC2", "PC3"],
|
|
format_func=lambda p: f"{p} — {PC_LABELS[p]}")
|
|
st.info(PC_BLURB[pick])
|
|
with rcol:
|
|
ranked = loadings[[pick]].copy().sort_values(pick)
|
|
ranked["ride"] = ranked.index
|
|
lim = ranked[pick].abs().max() # symmetric so the scale's white sits at 0
|
|
fig_load = px.bar(
|
|
ranked, x=pick, y="ride", orientation="h",
|
|
color=pick, color_continuous_scale="RdBu", range_color=[-lim, lim],
|
|
labels={pick: f"{pick} loading", "ride": ""},
|
|
height=max(420, 16 * len(ranked)),
|
|
)
|
|
fig_load.update_layout(coloraxis_showscale=False,
|
|
margin={"l": 0, "r": 0, "t": 10, "b": 0})
|
|
st.plotly_chart(fig_load, use_container_width=True)
|
|
|
|
st.markdown("---")
|
|
|
|
# --- the clock: each axis' average score by hour of day ---
|
|
st.markdown("#### How each axis moves over a day")
|
|
st.markdown(
|
|
"Average score for each component by hour. **PC1** climbs into the "
|
|
"evening as crowds build; **PC3** humps at midday (the school-group "
|
|
"window) then falls — the fingerprint of *time of day*."
|
|
)
|
|
by_hour = scores.groupby(scores.index.hour).mean()
|
|
by_hour.index.name = "hour"
|
|
hour_long = by_hour.reset_index().melt("hour", var_name="Component",
|
|
value_name="Mean score")
|
|
fig_hour = px.line(
|
|
hour_long, x="hour", y="Mean score", color="Component", markers=True,
|
|
labels={"hour": "Hour of day (local)"},
|
|
color_discrete_map={"PC1": "#2E8B57", "PC2": "#E8A33D", "PC3": "#4C9BE8"},
|
|
)
|
|
fig_hour.add_hline(y=0, line_width=0.5, line_color="grey")
|
|
st.plotly_chart(fig_hour, use_container_width=True)
|
|
|
|
# --- Footer ---
|
|
st.sidebar.markdown("---")
|
|
st.sidebar.caption(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
if st.sidebar.button("Force Refresh Data"):
|
|
st.cache_data.clear()
|
|
st.rerun()
|