diff --git a/app.py b/app.py index 1610af2..a77ad2c 100644 --- a/app.py +++ b/app.py @@ -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: diff --git a/requirements.txt b/requirements.txt index 0e7fc98..865dd81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ requests psycopg2-binary streamlit pandas +numpy plotly python-dotenv scikit-learn