- compute_pca now fits 4 components; PC4 added to PC_LABELS/PC_BLURB
("off-peak spread vs headliner pull", ~5% variance, framed as
diminishing returns).
- "Read an axis" loadings explorer gains PC4.
- The behavioral-map scatter becomes interactive: pick any two
components for the X/Y axes, and color by category tag, spatial
area, or any component's loading (continuous RdBu, symmetric).
- Headline caption pinned to evr[:3] so "3 axes = 60%" stays true;
the by-hour clock stays PC1-3 to preserve the three-axes story.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
617 lines
27 KiB
Python
617 lines
27 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",
|
||
"PC4": "Off-peak spread ↔ headliner pull",
|
||
}
|
||
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).",
|
||
"PC4": "The faint fourth axis (~5% of variance — diminishing returns). A "
|
||
"contrast between a few secondary rides (super round-up, tumbling "
|
||
"timbers, flying tigers — positive) and the headliners (Phoenix, "
|
||
"Impulse, Flying Turns — negative). Its score drifts with the "
|
||
"calendar: positive in quiet spring weekday mornings, negative on "
|
||
"busy summer/fall weekend evenings when demand piles onto the "
|
||
"marquee rides. Real, but mostly where the clean signal ends.",
|
||
}
|
||
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=4) # PC4 is faint (~5%) — exposed for exploration only
|
||
sc = pca.fit_transform(scaled)
|
||
|
||
pc_cols = ["PC1", "PC2", "PC3", "PC4"]
|
||
scores = pd.DataFrame(sc, index=matrix.index, columns=pc_cols)
|
||
loadings = pd.DataFrame(pca.components_.T, index=matrix.columns, columns=pc_cols)
|
||
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[:3].sum():.1%}** of all the variation in park wait times.")
|
||
|
||
st.markdown("---")
|
||
|
||
# --- the behavioral map: any two components, freely colored ---
|
||
st.markdown("#### The map of the park, drawn from wait times alone")
|
||
st.markdown(
|
||
"Each ride placed by two of its component loadings — its *behavioral* "
|
||
"position, drawn purely from how its queue moves over time. The "
|
||
"default (**PC2 × PC3**, colored by category) is the write-up's view: "
|
||
"Kiddieland huddles left, evening thrill rides sink bottom-right, "
|
||
"daytime family rides float to the top. **Swap either axis** to any "
|
||
"component, or **recolor by a third component's loading**, to hunt for "
|
||
"structure the default hides."
|
||
)
|
||
pcs = ["PC1", "PC2", "PC3", "PC4"]
|
||
axis_labels = {p: f"{p} · {PC_LABELS[p]}" for p in pcs}
|
||
axis_labels.update({"category": "Park's tag", "area": "Spatial area"})
|
||
mc1, mc2, mc3 = st.columns(3)
|
||
with mc1:
|
||
xpc = st.selectbox("X axis", pcs, index=1, key="map_x",
|
||
format_func=lambda p: f"{p} — {PC_LABELS[p]}")
|
||
with mc2:
|
||
ypc = st.selectbox("Y axis", pcs, index=2, key="map_y",
|
||
format_func=lambda p: f"{p} — {PC_LABELS[p]}")
|
||
with mc3:
|
||
color_opt = st.selectbox(
|
||
"Color by", ["Category tag", "Spatial area"] + [f"{p} loading" for p in pcs],
|
||
key="map_color")
|
||
|
||
if xpc == ypc:
|
||
st.info("Pick two *different* components for the axes.")
|
||
else:
|
||
plot_df = loadings.reset_index().copy()
|
||
plot_df["category"] = plot_df["category"].fillna("Untagged")
|
||
common = dict(x=xpc, y=ypc, text="ride_name", hover_name="ride_name",
|
||
height=620, labels=axis_labels)
|
||
if color_opt == "Category tag":
|
||
fig_map_pca = px.scatter(plot_df, color="category",
|
||
color_discrete_map={**CATEGORY_COLORS, "Untagged": "#888"}, **common)
|
||
elif color_opt == "Spatial area":
|
||
fig_map_pca = px.scatter(plot_df, color="area", **common)
|
||
else:
|
||
cpc = color_opt.split()[0] # "PC3 loading" -> "PC3"
|
||
lim = plot_df[cpc].abs().max() # symmetric scale → white at 0
|
||
fig_map_pca = px.scatter(plot_df, color=cpc, color_continuous_scale="RdBu",
|
||
range_color=[-lim, lim], **common)
|
||
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", "PC4"],
|
||
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[["PC1", "PC2", "PC3"]].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()
|