fmegahed commited on
Commit
7de4804
·
1 Parent(s): 3b66818

Increase developer card sizing to match design mockup

Browse files
Files changed (2) hide show
  1. app.py +459 -339
  2. src/ui_theme.py +71 -0
app.py CHANGED
@@ -132,6 +132,399 @@ def _querychat_fragment(cleaned_df, date_col, y_cols, freq_label):
132
  st.session_state.qc.ui()
133
 
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  def _render_cleaning_report(report: CleaningReport) -> None:
136
  """Show a data-quality card."""
137
  c1, c2, c3 = st.columns(3)
@@ -261,6 +654,60 @@ with st.sidebar:
261
  <span style="font-size:0.82rem; color:#000;">
262
  ISA 444 &middot; Miami University
263
  </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  </div>
265
  """,
266
  unsafe_allow_html=True,
@@ -345,6 +792,7 @@ with st.sidebar:
345
  st.session_state.freq_info = freq_info
346
  st.session_state._clean_key = _key
347
 
 
348
  freq_info = st.session_state.freq_info
349
  st.caption(f"Frequency: **{freq_info.label}** "
350
  f"({'regular' if freq_info.is_regular else 'irregular'})")
@@ -361,13 +809,15 @@ with st.sidebar:
361
  median_delta=freq_info.median_delta,
362
  is_regular=freq_info.is_regular,
363
  )
 
364
 
365
  # ------ QueryChat ------
366
  if check_querychat_available():
367
  st.divider()
368
  st.subheader("QueryChat")
369
- _querychat_fragment(cleaned_df, date_col, y_cols,
370
- st.session_state.freq_info.label)
 
371
  else:
372
  st.divider()
373
  st.info(
@@ -383,18 +833,6 @@ with st.sidebar:
383
  st.rerun()
384
 
385
  st.divider()
386
- st.markdown(
387
- """
388
- <div style="text-align:center; padding:0.5rem 0;">
389
- <span style="font-size:0.75rem; color:#000;">
390
- Developed by <strong>Fadel M. Megahed</strong><br>
391
- for <strong>ISA 444</strong> &middot; Miami University<br>
392
- Version <strong>0.1.0</strong>
393
- </span>
394
- </div>
395
- """,
396
- unsafe_allow_html=True,
397
- )
398
  st.caption(
399
  "**Privacy:** All processing is in-memory. "
400
  "If you click **Interpret Chart with AI**, the chart image is sent to OpenAI — "
@@ -429,9 +867,7 @@ else:
429
  working_df = cleaned_df
430
 
431
  # Data quality report
432
- if report is not None:
433
- with st.expander("Data Quality Report", expanded=False):
434
- _render_cleaning_report(report)
435
 
436
  # ---------------------------------------------------------------------------
437
  # Tabs
@@ -446,335 +882,19 @@ tab_single, tab_few, tab_many = st.tabs([
446
  # Tab A — Single Series
447
  # ===================================================================
448
  with tab_single:
449
- if len(y_cols) == 1:
450
- active_y = y_cols[0]
451
- else:
452
- active_y = st.selectbox("Select value column", y_cols, key="tab_a_y")
453
-
454
- # ---- Date range filter ------------------------------------------------
455
- dr_mode = st.radio(
456
- "Date range",
457
- ["All", "Last N years", "Custom"],
458
- horizontal=True,
459
- key="dr_mode",
460
- )
461
- df_plot = working_df.copy()
462
- if dr_mode == "Last N years":
463
- n_years = st.slider("Years", 1, 20, 5, key="dr_n")
464
- cutoff = df_plot[date_col].max() - pd.DateOffset(years=n_years)
465
- df_plot = df_plot[df_plot[date_col] >= cutoff]
466
- elif dr_mode == "Custom":
467
- d_min = df_plot[date_col].min().date()
468
- d_max = df_plot[date_col].max().date()
469
- sel = st.slider("Date range", d_min, d_max, (d_min, d_max), key="dr_custom")
470
- df_plot = df_plot[
471
- (df_plot[date_col].dt.date >= sel[0])
472
- & (df_plot[date_col].dt.date <= sel[1])
473
- ]
474
-
475
- if df_plot.empty:
476
- st.warning("No data in selected range.")
477
- st.stop()
478
-
479
- # ---- Chart controls ---------------------------------------------------
480
- col_chart, col_opts = st.columns([2, 1])
481
- with col_opts:
482
- chart_type = st.selectbox("Chart type", _CHART_TYPES, key="chart_type_a")
483
-
484
- palette_name = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_a")
485
- n_colors = max(12, len(y_cols))
486
- palette_colors = get_palette_colors(palette_name, n_colors)
487
- swatch_fig = render_palette_preview(palette_colors[:8])
488
- st.pyplot(swatch_fig, use_container_width=True)
489
-
490
- # Color-by control (for colored markers chart)
491
- color_by = None
492
- if chart_type == "Line – Colored Markers":
493
- if "month" in working_df.columns:
494
- color_by = st.selectbox(
495
- "Color by",
496
- ["month", "quarter", "year", "day_of_week"],
497
- key="color_by_a",
498
- )
499
- else:
500
- other_cols = [
501
- c for c in working_df.columns
502
- if c not in (date_col, active_y)
503
- ][:5]
504
- if other_cols:
505
- color_by = st.selectbox(
506
- "Color by", other_cols, key="color_by_a",
507
- )
508
-
509
- # Chart-specific controls
510
- period_label = "month"
511
- window_size = 12
512
- lag_val = 1
513
- decomp_model = "additive"
514
-
515
- if chart_type in ("Seasonal Plot", "Seasonal Sub-series"):
516
- period_label = st.selectbox("Period", ["month", "quarter"], key="period_a")
517
-
518
- if chart_type == "Rolling Mean Overlay":
519
- window_size = st.slider("Window", 2, 52, 12, key="window_a")
520
-
521
- if chart_type == "Lag Plot":
522
- lag_val = st.slider("Lag", 1, 52, 1, key="lag_a")
523
-
524
- if chart_type == "Decomposition":
525
- decomp_model = st.selectbox("Model", ["additive", "multiplicative"], key="decomp_a")
526
-
527
- # ---- Render chart -----------------------------------------------------
528
- with col_chart:
529
- fig = None
530
- try:
531
- if chart_type == "Line with Markers":
532
- fig = plot_line_with_markers(
533
- df_plot, date_col, active_y,
534
- title=f"{active_y} over Time",
535
- style_dict=style_dict, palette_colors=palette_colors,
536
- )
537
-
538
- elif chart_type == "Line – Colored Markers" and color_by is not None:
539
- fig = plot_line_colored_markers(
540
- df_plot, date_col, active_y,
541
- color_by=color_by, palette_colors=palette_colors,
542
- title=f"{active_y} colored by {color_by}",
543
- style_dict=style_dict,
544
- )
545
-
546
- elif chart_type == "Seasonal Plot":
547
- fig = plot_seasonal(
548
- df_plot, date_col, active_y,
549
- period=period_label,
550
- palette_name_colors=palette_colors,
551
- title=f"Seasonal Plot – {active_y}",
552
- style_dict=style_dict,
553
- )
554
-
555
- elif chart_type == "Seasonal Sub-series":
556
- fig = plot_seasonal_subseries(
557
- df_plot, date_col, active_y,
558
- period=period_label,
559
- title=f"Seasonal Sub-series – {active_y}",
560
- style_dict=style_dict, palette_colors=palette_colors,
561
- )
562
-
563
- elif chart_type == "ACF / PACF":
564
- series = df_plot[active_y].dropna()
565
- acf_vals, acf_ci, pacf_vals, pacf_ci = compute_acf_pacf(series)
566
- fig = plot_acf_pacf(
567
- acf_vals, acf_ci, pacf_vals, pacf_ci,
568
- title=f"ACF / PACF – {active_y}",
569
- style_dict=style_dict,
570
- )
571
-
572
- elif chart_type == "Decomposition":
573
- period_int = None
574
- if freq_info and freq_info.label == "Monthly":
575
- period_int = 12
576
- elif freq_info and freq_info.label == "Quarterly":
577
- period_int = 4
578
- elif freq_info and freq_info.label == "Weekly":
579
- period_int = 52
580
- elif freq_info and freq_info.label == "Daily":
581
- period_int = 365
582
-
583
- result = compute_decomposition(
584
- df_plot, date_col, active_y,
585
- model=decomp_model, period=period_int,
586
- )
587
- fig = plot_decomposition(
588
- result,
589
- title=f"Decomposition – {active_y} ({decomp_model})",
590
- style_dict=style_dict,
591
- )
592
-
593
- elif chart_type == "Rolling Mean Overlay":
594
- fig = plot_rolling_overlay(
595
- df_plot, date_col, active_y,
596
- window=window_size,
597
- title=f"Rolling {window_size}-pt Mean – {active_y}",
598
- style_dict=style_dict, palette_colors=palette_colors,
599
- )
600
-
601
- elif chart_type == "Year-over-Year Change":
602
- yoy_result = compute_yoy_change(df_plot, date_col, active_y)
603
- yoy_df = pd.DataFrame({
604
- "date": yoy_result[date_col],
605
- "abs_change": yoy_result["yoy_abs_change"],
606
- "pct_change": yoy_result["yoy_pct_change"],
607
- }).dropna()
608
- fig = plot_yoy_change(
609
- df_plot, date_col, active_y, yoy_df,
610
- title=f"Year-over-Year Change – {active_y}",
611
- style_dict=style_dict,
612
- )
613
-
614
- elif chart_type == "Lag Plot":
615
- fig = plot_lag(
616
- df_plot[active_y],
617
- lag=lag_val,
618
- title=f"Lag-{lag_val} Plot – {active_y}",
619
- style_dict=style_dict,
620
- )
621
-
622
- except Exception as exc:
623
- st.error(f"Chart error: {exc}")
624
-
625
- if fig is not None:
626
- st.pyplot(fig, use_container_width=True)
627
-
628
- # ---- Summary stats expander -------------------------------------------
629
- with st.expander("Summary Statistics", expanded=False):
630
- stats = compute_summary_stats(df_plot, date_col, active_y)
631
- _render_summary_stats(stats)
632
-
633
- # ---- AI Interpretation ------------------------------------------------
634
- _render_ai_interpretation(
635
- fig, chart_type, freq_info, df_plot, date_col, active_y, "interpret_a",
636
- )
637
 
638
  # ===================================================================
639
  # Tab B — Few Series (Panel)
640
  # ===================================================================
641
  with tab_few:
642
- if len(y_cols) < 2:
643
- st.info("Select 2+ value columns in the sidebar to use panel plots.")
644
- else:
645
- st.subheader("Panel Plot (Small Multiples)")
646
-
647
- if "panel_cols" not in st.session_state:
648
- st.session_state["panel_cols"] = y_cols[:4]
649
- else:
650
- st.session_state["panel_cols"] = [
651
- c for c in st.session_state["panel_cols"] if c in y_cols
652
- ]
653
- panel_cols = st.multiselect("Columns to plot", y_cols, key="panel_cols")
654
-
655
- if panel_cols:
656
- pc1, pc2 = st.columns(2)
657
- with pc1:
658
- panel_chart = st.selectbox(
659
- "Chart type", ["line", "bar"], key="panel_chart"
660
- )
661
- with pc2:
662
- if "panel_shared" not in st.session_state:
663
- st.session_state["panel_shared"] = True
664
- shared_y = st.checkbox("Shared Y axis", key="panel_shared")
665
-
666
- palette_name_b = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_b")
667
- palette_b = get_palette_colors(palette_name_b, len(panel_cols))
668
-
669
- fig_panel = None
670
- try:
671
- fig_panel = plot_panel(
672
- working_df, date_col, panel_cols,
673
- chart_type=panel_chart,
674
- shared_y=shared_y,
675
- title="Panel Comparison",
676
- style_dict=style_dict,
677
- palette_colors=palette_b,
678
- )
679
- st.pyplot(fig_panel, use_container_width=True)
680
- except Exception as exc:
681
- st.error(f"Panel chart error: {exc}")
682
-
683
- # Per-series summary table
684
- with st.expander("Per-series Summary", expanded=False):
685
- summary_df = compute_multi_series_summary(
686
- working_df, date_col, panel_cols,
687
- )
688
- st.dataframe(
689
- summary_df.style.format({
690
- "mean": "{:,.2f}",
691
- "std": "{:,.2f}",
692
- "min": "{:,.2f}",
693
- "max": "{:,.2f}",
694
- "trend_slope": "{:,.4f}",
695
- "adf_pvalue": "{:.4f}",
696
- }),
697
- use_container_width=True,
698
- )
699
-
700
- # AI Interpretation
701
- _render_ai_interpretation(
702
- fig_panel, f"Panel ({panel_chart})", freq_info,
703
- working_df, date_col, ", ".join(panel_cols), "interpret_b",
704
- )
705
 
706
  # ===================================================================
707
  # Tab C — Many Series (Spaghetti)
708
  # ===================================================================
709
  with tab_many:
710
- if len(y_cols) < 2:
711
- st.info("Select 2+ value columns in the sidebar to use spaghetti plots.")
712
- else:
713
- st.subheader("Spaghetti Plot")
714
-
715
- if "spag_cols" not in st.session_state:
716
- st.session_state["spag_cols"] = list(y_cols)
717
- else:
718
- st.session_state["spag_cols"] = [
719
- c for c in st.session_state["spag_cols"] if c in y_cols
720
- ]
721
- spag_cols = st.multiselect("Columns to include", y_cols, key="spag_cols")
722
-
723
- if spag_cols:
724
- sc1, sc2, sc3 = st.columns(3)
725
- with sc1:
726
- alpha_val = st.slider("Alpha", 0.05, 1.0, 0.15, 0.05, key="spag_alpha")
727
- with sc2:
728
- top_n = st.number_input("Highlight top N", 0, len(spag_cols), 0, key="spag_topn")
729
- top_n = top_n if top_n > 0 else None
730
- with sc3:
731
- highlight = st.selectbox(
732
- "Highlight series",
733
- ["(none)"] + spag_cols,
734
- key="spag_highlight",
735
- )
736
- highlight_col = highlight if highlight != "(none)" else None
737
-
738
- show_median = st.checkbox("Show Median + IQR band", key="spag_median")
739
-
740
- palette_name_c = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_c")
741
- palette_c = get_palette_colors(palette_name_c, len(spag_cols))
742
-
743
- fig_spag = None
744
- try:
745
- fig_spag = plot_spaghetti(
746
- working_df, date_col, spag_cols,
747
- alpha=alpha_val,
748
- highlight_col=highlight_col,
749
- top_n=top_n,
750
- show_median_band=show_median,
751
- title="Spaghetti Plot",
752
- style_dict=style_dict,
753
- palette_colors=palette_c,
754
- )
755
- st.pyplot(fig_spag, use_container_width=True)
756
- except Exception as exc:
757
- st.error(f"Spaghetti chart error: {exc}")
758
-
759
- # Per-series summary table
760
- with st.expander("Per-series Summary", expanded=False):
761
- spag_summary = compute_multi_series_summary(
762
- working_df, date_col, spag_cols,
763
- )
764
- st.dataframe(
765
- spag_summary.style.format({
766
- "mean": "{:,.2f}",
767
- "std": "{:,.2f}",
768
- "min": "{:,.2f}",
769
- "max": "{:,.2f}",
770
- "trend_slope": "{:,.4f}",
771
- "adf_pvalue": "{:.4f}",
772
- }),
773
- use_container_width=True,
774
- )
775
-
776
- # AI Interpretation
777
- _render_ai_interpretation(
778
- fig_spag, "Spaghetti Plot", freq_info,
779
- working_df, date_col, ", ".join(spag_cols), "interpret_c",
780
- )
 
132
  st.session_state.qc.ui()
133
 
134
 
135
+ @st.fragment
136
+ def _data_quality_fragment(report: CleaningReport | None) -> None:
137
+ if report is None:
138
+ return
139
+ with st.expander("Data Quality Report", expanded=False):
140
+ _render_cleaning_report(report)
141
+
142
+
143
+ @st.fragment
144
+ def _single_chart_fragment(working_df, date_col, y_cols, freq_info, style_dict):
145
+ if len(y_cols) == 1:
146
+ active_y = y_cols[0]
147
+ else:
148
+ active_y = st.selectbox("Select value column", y_cols, key="tab_a_y")
149
+
150
+ # ---- Date range filter ------------------------------------------------
151
+ dr_mode = st.radio(
152
+ "Date range",
153
+ ["All", "Last N years", "Custom"],
154
+ horizontal=True,
155
+ key="dr_mode",
156
+ )
157
+ df_plot = working_df.copy()
158
+ if dr_mode == "Last N years":
159
+ n_years = st.slider("Years", 1, 20, 5, key="dr_n")
160
+ cutoff = df_plot[date_col].max() - pd.DateOffset(years=n_years)
161
+ df_plot = df_plot[df_plot[date_col] >= cutoff]
162
+ elif dr_mode == "Custom":
163
+ d_min = df_plot[date_col].min().date()
164
+ d_max = df_plot[date_col].max().date()
165
+ sel = st.slider("Date range", d_min, d_max, (d_min, d_max), key="dr_custom")
166
+ df_plot = df_plot[
167
+ (df_plot[date_col].dt.date >= sel[0])
168
+ & (df_plot[date_col].dt.date <= sel[1])
169
+ ]
170
+
171
+ if df_plot.empty:
172
+ st.warning("No data in selected range.")
173
+ st.session_state["_single_df_plot"] = None
174
+ st.session_state["_single_fig"] = None
175
+ st.session_state["_single_active_y"] = None
176
+ st.session_state["_single_chart_type"] = None
177
+ return
178
+
179
+ # ---- Chart controls ---------------------------------------------------
180
+ col_chart, col_opts = st.columns([2, 1])
181
+ with col_opts:
182
+ chart_type = st.selectbox("Chart type", _CHART_TYPES, key="chart_type_a")
183
+
184
+ palette_name = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_a")
185
+ n_colors = max(12, len(y_cols))
186
+ palette_colors = get_palette_colors(palette_name, n_colors)
187
+ swatch_fig = render_palette_preview(palette_colors[:8])
188
+ st.pyplot(swatch_fig, use_container_width=True)
189
+
190
+ # Color-by control (for colored markers chart)
191
+ color_by = None
192
+ if chart_type == "Line – Colored Markers":
193
+ if "month" in working_df.columns:
194
+ color_by = st.selectbox(
195
+ "Color by",
196
+ ["month", "quarter", "year", "day_of_week"],
197
+ key="color_by_a",
198
+ )
199
+ else:
200
+ other_cols = [
201
+ c for c in working_df.columns
202
+ if c not in (date_col, active_y)
203
+ ][:5]
204
+ if other_cols:
205
+ color_by = st.selectbox(
206
+ "Color by", other_cols, key="color_by_a",
207
+ )
208
+
209
+ # Chart-specific controls
210
+ period_label = "month"
211
+ window_size = 12
212
+ lag_val = 1
213
+ decomp_model = "additive"
214
+
215
+ if chart_type in ("Seasonal Plot", "Seasonal Sub-series"):
216
+ period_label = st.selectbox("Period", ["month", "quarter"], key="period_a")
217
+
218
+ if chart_type == "Rolling Mean Overlay":
219
+ window_size = st.slider("Window", 2, 52, 12, key="window_a")
220
+
221
+ if chart_type == "Lag Plot":
222
+ lag_val = st.slider("Lag", 1, 52, 1, key="lag_a")
223
+
224
+ if chart_type == "Decomposition":
225
+ decomp_model = st.selectbox("Model", ["additive", "multiplicative"], key="decomp_a")
226
+
227
+ # ---- Render chart -----------------------------------------------------
228
+ with col_chart:
229
+ fig = None
230
+ try:
231
+ if chart_type == "Line with Markers":
232
+ fig = plot_line_with_markers(
233
+ df_plot, date_col, active_y,
234
+ title=f"{active_y} over Time",
235
+ style_dict=style_dict, palette_colors=palette_colors,
236
+ )
237
+
238
+ elif chart_type == "Line – Colored Markers" and color_by is not None:
239
+ fig = plot_line_colored_markers(
240
+ df_plot, date_col, active_y,
241
+ color_by=color_by, palette_colors=palette_colors,
242
+ title=f"{active_y} colored by {color_by}",
243
+ style_dict=style_dict,
244
+ )
245
+
246
+ elif chart_type == "Seasonal Plot":
247
+ fig = plot_seasonal(
248
+ df_plot, date_col, active_y,
249
+ period=period_label,
250
+ palette_name_colors=palette_colors,
251
+ title=f"Seasonal Plot – {active_y}",
252
+ style_dict=style_dict,
253
+ )
254
+
255
+ elif chart_type == "Seasonal Sub-series":
256
+ fig = plot_seasonal_subseries(
257
+ df_plot, date_col, active_y,
258
+ period=period_label,
259
+ title=f"Seasonal Sub-series – {active_y}",
260
+ style_dict=style_dict, palette_colors=palette_colors,
261
+ )
262
+
263
+ elif chart_type == "ACF / PACF":
264
+ series = df_plot[active_y].dropna()
265
+ acf_vals, acf_ci, pacf_vals, pacf_ci = compute_acf_pacf(series)
266
+ fig = plot_acf_pacf(
267
+ acf_vals, acf_ci, pacf_vals, pacf_ci,
268
+ title=f"ACF / PACF – {active_y}",
269
+ style_dict=style_dict,
270
+ )
271
+
272
+ elif chart_type == "Decomposition":
273
+ period_int = None
274
+ if freq_info and freq_info.label == "Monthly":
275
+ period_int = 12
276
+ elif freq_info and freq_info.label == "Quarterly":
277
+ period_int = 4
278
+ elif freq_info and freq_info.label == "Weekly":
279
+ period_int = 52
280
+ elif freq_info and freq_info.label == "Daily":
281
+ period_int = 365
282
+
283
+ result = compute_decomposition(
284
+ df_plot, date_col, active_y,
285
+ model=decomp_model, period=period_int,
286
+ )
287
+ fig = plot_decomposition(
288
+ result,
289
+ title=f"Decomposition – {active_y} ({decomp_model})",
290
+ style_dict=style_dict,
291
+ )
292
+
293
+ elif chart_type == "Rolling Mean Overlay":
294
+ fig = plot_rolling_overlay(
295
+ df_plot, date_col, active_y,
296
+ window=window_size,
297
+ title=f"Rolling {window_size}-pt Mean – {active_y}",
298
+ style_dict=style_dict, palette_colors=palette_colors,
299
+ )
300
+
301
+ elif chart_type == "Year-over-Year Change":
302
+ yoy_result = compute_yoy_change(df_plot, date_col, active_y)
303
+ yoy_df = pd.DataFrame({
304
+ "date": yoy_result[date_col],
305
+ "abs_change": yoy_result["yoy_abs_change"],
306
+ "pct_change": yoy_result["yoy_pct_change"],
307
+ }).dropna()
308
+ fig = plot_yoy_change(
309
+ df_plot, date_col, active_y, yoy_df,
310
+ title=f"Year-over-Year Change – {active_y}",
311
+ style_dict=style_dict,
312
+ )
313
+
314
+ elif chart_type == "Lag Plot":
315
+ fig = plot_lag(
316
+ df_plot[active_y],
317
+ lag=lag_val,
318
+ title=f"Lag-{lag_val} Plot – {active_y}",
319
+ style_dict=style_dict,
320
+ )
321
+
322
+ except Exception as exc:
323
+ st.error(f"Chart error: {exc}")
324
+
325
+ if fig is not None:
326
+ st.pyplot(fig, use_container_width=True)
327
+
328
+ st.session_state["_single_df_plot"] = df_plot
329
+ st.session_state["_single_fig"] = fig
330
+ st.session_state["_single_active_y"] = active_y
331
+ st.session_state["_single_chart_type"] = chart_type
332
+
333
+
334
+ @st.fragment
335
+ def _single_insights_fragment(freq_info, date_col):
336
+ df_plot = st.session_state.get("_single_df_plot")
337
+ active_y = st.session_state.get("_single_active_y")
338
+ chart_type = st.session_state.get("_single_chart_type")
339
+ fig = st.session_state.get("_single_fig")
340
+
341
+ if df_plot is None or active_y is None:
342
+ return
343
+
344
+ # ---- Summary stats expander -------------------------------------------
345
+ with st.expander("Summary Statistics", expanded=False):
346
+ stats = compute_summary_stats(df_plot, date_col, active_y)
347
+ _render_summary_stats(stats)
348
+
349
+ # ---- AI Interpretation ------------------------------------------------
350
+ _render_ai_interpretation(
351
+ fig, chart_type, freq_info, df_plot, date_col, active_y, "interpret_a",
352
+ )
353
+
354
+
355
+ @st.fragment
356
+ def _panel_chart_fragment(working_df, date_col, y_cols, style_dict):
357
+ if len(y_cols) < 2:
358
+ st.info("Select 2+ value columns in the sidebar to use panel plots.")
359
+ st.session_state["_panel_fig"] = None
360
+ return
361
+
362
+ st.subheader("Panel Plot (Small Multiples)")
363
+
364
+ if "panel_cols" not in st.session_state:
365
+ st.session_state["panel_cols"] = y_cols[:4]
366
+ else:
367
+ st.session_state["panel_cols"] = [
368
+ c for c in st.session_state["panel_cols"] if c in y_cols
369
+ ]
370
+ panel_cols = st.multiselect("Columns to plot", y_cols, key="panel_cols")
371
+
372
+ if panel_cols:
373
+ pc1, pc2 = st.columns(2)
374
+ with pc1:
375
+ panel_chart = st.selectbox(
376
+ "Chart type", ["line", "bar"], key="panel_chart"
377
+ )
378
+ with pc2:
379
+ if "panel_shared" not in st.session_state:
380
+ st.session_state["panel_shared"] = True
381
+ shared_y = st.checkbox("Shared Y axis", key="panel_shared")
382
+
383
+ palette_name_b = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_b")
384
+ palette_b = get_palette_colors(palette_name_b, len(panel_cols))
385
+
386
+ fig_panel = None
387
+ try:
388
+ fig_panel = plot_panel(
389
+ working_df, date_col, panel_cols,
390
+ chart_type=panel_chart,
391
+ shared_y=shared_y,
392
+ title="Panel Comparison",
393
+ style_dict=style_dict,
394
+ palette_colors=palette_b,
395
+ )
396
+ st.pyplot(fig_panel, use_container_width=True)
397
+ except Exception as exc:
398
+ st.error(f"Panel chart error: {exc}")
399
+
400
+ st.session_state["_panel_fig"] = fig_panel
401
+ else:
402
+ st.session_state["_panel_fig"] = None
403
+
404
+
405
+ @st.fragment
406
+ def _panel_insights_fragment(working_df, date_col, freq_info):
407
+ panel_cols = st.session_state.get("panel_cols") or []
408
+ fig_panel = st.session_state.get("_panel_fig")
409
+ panel_chart = st.session_state.get("panel_chart", "line")
410
+
411
+ if not panel_cols:
412
+ return
413
+
414
+ # Per-series summary table
415
+ with st.expander("Per-series Summary", expanded=False):
416
+ summary_df = compute_multi_series_summary(
417
+ working_df, date_col, panel_cols,
418
+ )
419
+ st.dataframe(
420
+ summary_df.style.format({
421
+ "mean": "{:,.2f}",
422
+ "std": "{:,.2f}",
423
+ "min": "{:,.2f}",
424
+ "max": "{:,.2f}",
425
+ "trend_slope": "{:,.4f}",
426
+ "adf_pvalue": "{:.4f}",
427
+ }),
428
+ use_container_width=True,
429
+ )
430
+
431
+ # AI Interpretation
432
+ _render_ai_interpretation(
433
+ fig_panel, f"Panel ({panel_chart})", freq_info,
434
+ working_df, date_col, ", ".join(panel_cols), "interpret_b",
435
+ )
436
+
437
+
438
+ @st.fragment
439
+ def _spaghetti_chart_fragment(working_df, date_col, y_cols, style_dict):
440
+ if len(y_cols) < 2:
441
+ st.info("Select 2+ value columns in the sidebar to use spaghetti plots.")
442
+ st.session_state["_spag_fig"] = None
443
+ return
444
+
445
+ st.subheader("Spaghetti Plot")
446
+
447
+ if "spag_cols" not in st.session_state:
448
+ st.session_state["spag_cols"] = list(y_cols)
449
+ else:
450
+ st.session_state["spag_cols"] = [
451
+ c for c in st.session_state["spag_cols"] if c in y_cols
452
+ ]
453
+ spag_cols = st.multiselect("Columns to include", y_cols, key="spag_cols")
454
+
455
+ if spag_cols:
456
+ sc1, sc2, sc3 = st.columns(3)
457
+ with sc1:
458
+ alpha_val = st.slider("Alpha", 0.05, 1.0, 0.15, 0.05, key="spag_alpha")
459
+ with sc2:
460
+ top_n = st.number_input("Highlight top N", 0, len(spag_cols), 0, key="spag_topn")
461
+ top_n = top_n if top_n > 0 else None
462
+ with sc3:
463
+ highlight = st.selectbox(
464
+ "Highlight series",
465
+ ["(none)"] + spag_cols,
466
+ key="spag_highlight",
467
+ )
468
+ highlight_col = highlight if highlight != "(none)" else None
469
+
470
+ show_median = st.checkbox("Show Median + IQR band", key="spag_median")
471
+
472
+ palette_name_c = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_c")
473
+ palette_c = get_palette_colors(palette_name_c, len(spag_cols))
474
+
475
+ fig_spag = None
476
+ try:
477
+ fig_spag = plot_spaghetti(
478
+ working_df, date_col, spag_cols,
479
+ alpha=alpha_val,
480
+ highlight_col=highlight_col,
481
+ top_n=top_n,
482
+ show_median_band=show_median,
483
+ title="Spaghetti Plot",
484
+ style_dict=style_dict,
485
+ palette_colors=palette_c,
486
+ )
487
+ st.pyplot(fig_spag, use_container_width=True)
488
+ except Exception as exc:
489
+ st.error(f"Spaghetti chart error: {exc}")
490
+
491
+ st.session_state["_spag_fig"] = fig_spag
492
+ else:
493
+ st.session_state["_spag_fig"] = None
494
+
495
+
496
+ @st.fragment
497
+ def _spaghetti_insights_fragment(working_df, date_col, freq_info):
498
+ spag_cols = st.session_state.get("spag_cols") or []
499
+ fig_spag = st.session_state.get("_spag_fig")
500
+
501
+ if not spag_cols:
502
+ return
503
+
504
+ # Per-series summary table
505
+ with st.expander("Per-series Summary", expanded=False):
506
+ spag_summary = compute_multi_series_summary(
507
+ working_df, date_col, spag_cols,
508
+ )
509
+ st.dataframe(
510
+ spag_summary.style.format({
511
+ "mean": "{:,.2f}",
512
+ "std": "{:,.2f}",
513
+ "min": "{:,.2f}",
514
+ "max": "{:,.2f}",
515
+ "trend_slope": "{:,.4f}",
516
+ "adf_pvalue": "{:.4f}",
517
+ }),
518
+ use_container_width=True,
519
+ )
520
+
521
+ # AI Interpretation
522
+ _render_ai_interpretation(
523
+ fig_spag, "Spaghetti Plot", freq_info,
524
+ working_df, date_col, ", ".join(spag_cols), "interpret_c",
525
+ )
526
+
527
+
528
  def _render_cleaning_report(report: CleaningReport) -> None:
529
  """Show a data-quality card."""
530
  c1, c2, c3 = st.columns(3)
 
654
  <span style="font-size:0.82rem; color:#000;">
655
  ISA 444 &middot; Miami University
656
  </span>
657
+ <div style="margin-top:0.35rem; font-size:0.75rem; color:#000;">
658
+ Vibe-Coded by <strong>Fadel M. Megahed</strong><br>
659
+ Version <strong>0.2.0</strong>
660
+ </div>
661
+ </div>
662
+ """,
663
+ unsafe_allow_html=True,
664
+ )
665
+ st.divider()
666
+ st.subheader("Developer")
667
+ st.markdown(
668
+ """
669
+ <div class="dev-card">
670
+ <div class="dev-row">
671
+ <div class="dev-avatar">
672
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
673
+ <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"/>
674
+ <path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m8-7a7 7 0 0 0-5.468 11.37c.69-1.198 1.97-2.015 3.526-2.015h3.884c1.556 0 2.835.817 3.526 2.014A7 7 0 0 0 8 1"/>
675
+ </svg>
676
+ </div>
677
+ <div>
678
+ <div class="dev-name">Fadel M. Megahed</div>
679
+ <div class="dev-role">
680
+ Raymond E. Glos Professor, Farmer School of Business<br>
681
+ Miami University
682
+ </div>
683
+ </div>
684
+ </div>
685
+ <div class="dev-links">
686
+ <a class="dev-link" href="mailto:fmegahed@miamioh.edu">
687
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
688
+ <path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2zm13 2.383-4.708 2.825L15 11.105zM14.247 12.6 9.114 8.98 8 9.67 6.886 8.98 1.753 12.6A1 1 0 0 0 2 13h12a1 1 0 0 0 .247-.4zM1 11.105l4.708-2.897L1 5.383z"/>
689
+ </svg>
690
+ Email
691
+ </a>
692
+ <a class="dev-link" href="https://www.linkedin.com/in/fadel-megahed-289046b4/" target="_blank">
693
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
694
+ <path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.475 0 16 .513 16 1.146v13.708c0 .633-.525 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zM4.943 13.5V6H2.542v7.5zM3.743 4.927c.837 0 1.358-.554 1.358-1.248-.015-.709-.521-1.248-1.342-1.248-.821 0-1.358.54-1.358 1.248 0 .694.521 1.248 1.327 1.248zm4.908 8.573V9.359c0-.22.016-.44.08-.598.176-.44.576-.897 1.248-.897.88 0 1.232.676 1.232 1.667v4.0h2.401V9.247c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193h.016V6H6.35c.03.7 0 7.5 0 7.5z"/>
695
+ </svg>
696
+ LinkedIn
697
+ </a>
698
+ <a class="dev-link" href="https://miamioh.edu/fsb/directory/?up=/directory/megahefm" target="_blank">
699
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
700
+ <path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m7-7a7 7 0 0 0-2.468.45c.303.393.58.825.82 1.3A5.5 5.5 0 0 1 7 3.5zm2 0v2.5a5.5 5.5 0 0 1 1.648-.75 7 7 0 0 0-.82-1.3A7 7 0 0 0 9 1m3.97 3.06a6.5 6.5 0 0 0-1.71-.9c.21.53.36 1.1.44 1.69h2.21a7 7 0 0 0-.94-.79M15 8a7 7 0 0 0-.33-2h-2.34a6.5 6.5 0 0 1 0 4h2.34c.22-.64.33-1.32.33-2m-1.03 3.94a7 7 0 0 0 .94-.79h-2.21a6.5 6.5 0 0 1-.44 1.69c.62-.22 1.2-.53 1.71-.9M9 15a7 7 0 0 0 1.648-.75c.24-.48.517-.91.82-1.3A7 7 0 0 0 9 15m-2 0v-2.5a5.5 5.5 0 0 1-1.648.75c.24.48.517.91.82 1.3A7 7 0 0 0 7 15M4.03 11.94a6.5 6.5 0 0 0 1.71.9A6.5 6.5 0 0 1 5.3 11.15H3.09c.25.3.58.57.94.79M1 8c0 .68.11 1.36.33 2h2.34a6.5 6.5 0 0 1 0-4H1.33A7 7 0 0 0 1 8m1.03-3.94c.36.37.78.68 1.24.9a6.5 6.5 0 0 1 .44-1.69H2.06a7 7 0 0 0-.03.79"/>
701
+ </svg>
702
+ Website
703
+ </a>
704
+ <a class="dev-link" href="https://github.com/fmegahed/" target="_blank">
705
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
706
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/>
707
+ </svg>
708
+ GitHub
709
+ </a>
710
+ </div>
711
  </div>
712
  """,
713
  unsafe_allow_html=True,
 
792
  st.session_state.freq_info = freq_info
793
  st.session_state._clean_key = _key
794
 
795
+ cleaned_df = st.session_state.cleaned_df
796
  freq_info = st.session_state.freq_info
797
  st.caption(f"Frequency: **{freq_info.label}** "
798
  f"({'regular' if freq_info.is_regular else 'irregular'})")
 
809
  median_delta=freq_info.median_delta,
810
  is_regular=freq_info.is_regular,
811
  )
812
+ freq_info = st.session_state.freq_info
813
 
814
  # ------ QueryChat ------
815
  if check_querychat_available():
816
  st.divider()
817
  st.subheader("QueryChat")
818
+ if cleaned_df is not None:
819
+ _querychat_fragment(cleaned_df, date_col, y_cols,
820
+ st.session_state.freq_info.label)
821
  else:
822
  st.divider()
823
  st.info(
 
833
  st.rerun()
834
 
835
  st.divider()
 
 
 
 
 
 
 
 
 
 
 
 
836
  st.caption(
837
  "**Privacy:** All processing is in-memory. "
838
  "If you click **Interpret Chart with AI**, the chart image is sent to OpenAI — "
 
867
  working_df = cleaned_df
868
 
869
  # Data quality report
870
+ _data_quality_fragment(report)
 
 
871
 
872
  # ---------------------------------------------------------------------------
873
  # Tabs
 
882
  # Tab A — Single Series
883
  # ===================================================================
884
  with tab_single:
885
+ _single_chart_fragment(working_df, date_col, y_cols, freq_info, style_dict)
886
+ _single_insights_fragment(freq_info, date_col)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
887
 
888
  # ===================================================================
889
  # Tab B — Few Series (Panel)
890
  # ===================================================================
891
  with tab_few:
892
+ _panel_chart_fragment(working_df, date_col, y_cols, style_dict)
893
+ _panel_insights_fragment(working_df, date_col, freq_info)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
894
 
895
  # ===================================================================
896
  # Tab C — Many Series (Spaghetti)
897
  # ===================================================================
898
  with tab_many:
899
+ _spaghetti_chart_fragment(working_df, date_col, y_cols, style_dict)
900
+ _spaghetti_insights_fragment(working_df, date_col, freq_info)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ui_theme.py CHANGED
@@ -102,6 +102,77 @@ def apply_miami_theme() -> None:
102
  color: {_BLACK};
103
  font-weight: 700;
104
  }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  </style>
106
  """
107
  st.markdown(css, unsafe_allow_html=True)
 
102
  color: {_BLACK};
103
  font-weight: 700;
104
  }}
105
+
106
+ /* ---- Sidebar developer card ---- */
107
+ .dev-card {{
108
+ border: 1px solid {_BORDER_GRAY};
109
+ border-radius: 8px;
110
+ padding: 0.75rem;
111
+ background: {_WHITE};
112
+ }}
113
+ .dev-row {{
114
+ display: flex;
115
+ gap: 0.6rem;
116
+ align-items: flex-start;
117
+ }}
118
+ .dev-avatar {{
119
+ width: 42px;
120
+ height: 42px;
121
+ min-width: 42px;
122
+ border-radius: 50%;
123
+ background: {_LIGHT_GRAY};
124
+ color: {_BLACK};
125
+ border: 1px solid {_BORDER_GRAY};
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ }}
130
+ .dev-avatar svg {{
131
+ width: 24px;
132
+ height: 24px;
133
+ fill: #666;
134
+ }}
135
+ .dev-name {{
136
+ font-weight: 700;
137
+ color: {_BLACK};
138
+ font-size: 0.88rem;
139
+ line-height: 1.3;
140
+ }}
141
+ .dev-role {{
142
+ font-size: 0.74rem;
143
+ color: #5f6b73;
144
+ line-height: 1.3;
145
+ margin-top: 0.1rem;
146
+ }}
147
+ .dev-links {{
148
+ display: flex;
149
+ gap: 0.35rem;
150
+ flex-wrap: wrap;
151
+ margin-top: 0.55rem;
152
+ }}
153
+ .dev-link {{
154
+ display: inline-flex;
155
+ align-items: center;
156
+ gap: 0.25rem;
157
+ padding: 0.25rem 0.5rem;
158
+ border: 1px solid #C6C6C6;
159
+ border-radius: 5px;
160
+ font-size: 0.73rem;
161
+ color: #1a1a1a;
162
+ text-decoration: none;
163
+ background: {_WHITE};
164
+ line-height: 1.2;
165
+ white-space: nowrap;
166
+ }}
167
+ .dev-link svg {{
168
+ width: 13px;
169
+ height: 13px;
170
+ fill: currentColor;
171
+ }}
172
+ .dev-link:hover {{
173
+ border-color: {MIAMI_RED};
174
+ color: {MIAMI_RED};
175
+ }}
176
  </style>
177
  """
178
  st.markdown(css, unsafe_allow_html=True)