Add interactive 3D PCA views to dashboard Park Mind tab

Two spin/zoom Plotly scatter_3d sub-tabs in the PCA tab:
- Rides in PC1xPC2xPC3 (loadings), color by category / GPS
  k-means area / PC1 busyness. Adds GPS k-means areas to
  compute_pca(), auto-labeling the lowest-PC2 cluster Kiddieland.
- Moments in PC1xPC2xPC3 (per-snapshot scores), color by hour /
  day-of-week / weekend / month, sampled to 15k points if larger.

Pin numpy in requirements (now imported directly).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wes
2026-05-27 18:53:55 -04:00
co-authored by Claude Opus 4.7
parent ade774f5aa
commit 53109bc7fe
2 changed files with 112 additions and 0 deletions
+111
View File
@@ -1,6 +1,7 @@
# 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
@@ -113,6 +114,7 @@ def compute_pca():
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:
@@ -167,6 +169,20 @@ def compute_pca():
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. Label the one whose rides lean most negative
# on PC2 as Kiddieland so the colors read in plain English.
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"}
loadings["area"] = area.map(lambda a: names.get(a, f"Area {a + 1}"))
else:
loadings["area"] = "Untagged"
return {
"loadings": loadings,
"scores": scores,
@@ -415,6 +431,101 @@ with tab4:
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: