Files
kuh-no-bowls/app.py
T
wesandClaude Opus 4.7 ad163cb996 Expose PC4 and make the PCA behavioral map configurable
- 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>
2026-05-27 19:54:52 -04:00

617 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()