fmegahed commited on
Commit
9e5d7c3
·
1 Parent(s): dafe1bf

Reduce HF flicker with submit-based view updates and cached summaries

Browse files
Files changed (1) hide show
  1. app.py +230 -203
app.py CHANGED
@@ -106,8 +106,11 @@ _ANALYSIS_STATE_KEYS = [
106
  "tab_a_y", "dr_mode", "dr_n", "dr_custom",
107
  "chart_type_a", "pal_a", "color_by_a", "period_a", "window_a", "lag_a", "decomp_a",
108
  "_single_df_plot", "_single_fig", "_single_active_y", "_single_chart_type",
 
109
  "panel_cols", "panel_chart", "panel_shared", "pal_b", "_panel_fig",
 
110
  "spag_cols", "spag_alpha", "spag_topn", "spag_highlight", "spag_median", "pal_c", "_spag_fig",
 
111
  ]
112
 
113
 
@@ -208,53 +211,49 @@ def _data_quality_fragment(report: CleaningReport | None) -> None:
208
  @st.fragment
209
  def _single_chart_fragment(working_df, date_col, y_cols, freq_info, style_dict):
210
  if len(y_cols) == 1:
211
- active_y = y_cols[0]
212
- else:
213
- active_y = st.selectbox("Select value column", y_cols, key="tab_a_y")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
- # ---- Date range filter ------------------------------------------------
216
- dr_mode = st.radio(
217
- "Date range",
218
- ["All", "Last N years", "Custom"],
219
- horizontal=True,
220
- key="dr_mode",
221
- )
222
- df_plot = working_df.copy()
223
- if dr_mode == "Last N years":
224
- n_years = st.slider("Years", 1, 20, 5, key="dr_n")
225
- cutoff = df_plot[date_col].max() - pd.DateOffset(years=n_years)
226
- df_plot = df_plot[df_plot[date_col] >= cutoff]
227
- elif dr_mode == "Custom":
228
- d_min = df_plot[date_col].min().date()
229
- d_max = df_plot[date_col].max().date()
230
- sel = st.slider("Date range", d_min, d_max, (d_min, d_max), key="dr_custom")
231
- df_plot = df_plot[
232
- (df_plot[date_col].dt.date >= sel[0])
233
- & (df_plot[date_col].dt.date <= sel[1])
234
- ]
235
-
236
- if df_plot.empty:
237
- st.warning("No data in selected range.")
238
- st.session_state["_single_df_plot"] = None
239
- st.session_state["_single_fig"] = None
240
- st.session_state["_single_active_y"] = None
241
- st.session_state["_single_chart_type"] = None
242
- return
243
 
244
- # ---- Chart controls ---------------------------------------------------
245
- col_chart, col_opts = st.columns([2, 1])
246
- with col_opts:
247
  chart_type = st.selectbox("Chart type", _CHART_TYPES, key="chart_type_a")
248
-
249
  palette_name = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_a")
250
- n_colors = max(12, len(y_cols))
251
- palette_colors = get_palette_colors(palette_name, n_colors)
252
  swatch_fig = render_palette_preview(palette_colors[:8])
253
  st.pyplot(swatch_fig, width="stretch")
254
 
255
- # Color-by control (for colored markers chart)
256
  color_by = None
257
- if chart_type == "Line – Colored Markers":
258
  if "month" in working_df.columns:
259
  color_by = st.selectbox(
260
  "Color by",
@@ -262,16 +261,10 @@ def _single_chart_fragment(working_df, date_col, y_cols, freq_info, style_dict):
262
  key="color_by_a",
263
  )
264
  else:
265
- other_cols = [
266
- c for c in working_df.columns
267
- if c not in (date_col, active_y)
268
- ][:5]
269
  if other_cols:
270
- color_by = st.selectbox(
271
- "Color by", other_cols, key="color_by_a",
272
- )
273
 
274
- # Chart-specific controls
275
  period_label = "month"
276
  window_size = 12
277
  lag_val = 1
@@ -279,121 +272,139 @@ def _single_chart_fragment(working_df, date_col, y_cols, freq_info, style_dict):
279
 
280
  if chart_type in ("Seasonal Plot", "Seasonal Sub-series"):
281
  period_label = st.selectbox("Period", ["month", "quarter"], key="period_a")
282
-
283
  if chart_type == "Rolling Mean Overlay":
284
  window_size = st.slider("Window", 2, 52, 12, key="window_a")
285
-
286
  if chart_type == "Lag Plot":
287
  lag_val = st.slider("Lag", 1, 52, 1, key="lag_a")
288
-
289
  if chart_type == "Decomposition":
290
  decomp_model = st.selectbox("Model", ["additive", "multiplicative"], key="decomp_a")
291
 
292
- # ---- Render chart -----------------------------------------------------
293
- with col_chart:
 
 
 
 
 
 
 
 
294
  fig = None
295
- try:
296
- if chart_type == "Line with Markers":
297
- fig = plot_line_with_markers(
298
- df_plot, date_col, active_y,
299
- title=f"{active_y} over Time",
300
- style_dict=style_dict, palette_colors=palette_colors,
301
- )
 
 
 
 
 
302
 
303
- elif chart_type == "Line – Colored Markers" and color_by is not None:
304
- fig = plot_line_colored_markers(
305
- df_plot, date_col, active_y,
306
- color_by=color_by, palette_colors=palette_colors,
307
- title=f"{active_y} colored by {color_by}",
308
- style_dict=style_dict,
309
- )
310
 
311
- elif chart_type == "Seasonal Plot":
312
- fig = plot_seasonal(
313
- df_plot, date_col, active_y,
314
- period=period_label,
315
- palette_name_colors=palette_colors,
316
- title=f"Seasonal Plot – {active_y}",
317
- style_dict=style_dict,
318
- )
319
 
320
- elif chart_type == "Seasonal Sub-series":
321
- fig = plot_seasonal_subseries(
322
- df_plot, date_col, active_y,
323
- period=period_label,
324
- title=f"Seasonal Sub-series – {active_y}",
325
- style_dict=style_dict, palette_colors=palette_colors,
326
- )
327
 
328
- elif chart_type == "ACF / PACF":
329
- series = df_plot[active_y].dropna()
330
- acf_vals, acf_ci, pacf_vals, pacf_ci = compute_acf_pacf(series)
331
- fig = plot_acf_pacf(
332
- acf_vals, acf_ci, pacf_vals, pacf_ci,
333
- title=f"ACF / PACF – {active_y}",
334
- style_dict=style_dict,
335
- )
336
 
337
- elif chart_type == "Decomposition":
338
- period_int = None
339
- if freq_info and freq_info.label == "Monthly":
340
- period_int = 12
341
- elif freq_info and freq_info.label == "Quarterly":
342
- period_int = 4
343
- elif freq_info and freq_info.label == "Weekly":
344
- period_int = 52
345
- elif freq_info and freq_info.label == "Daily":
346
- period_int = 365
347
-
348
- result = compute_decomposition(
349
- df_plot, date_col, active_y,
350
- model=decomp_model, period=period_int,
351
- )
352
- fig = plot_decomposition(
353
- result,
354
- title=f"Decomposition – {active_y} ({decomp_model})",
355
- style_dict=style_dict,
356
- )
357
 
358
- elif chart_type == "Rolling Mean Overlay":
359
- fig = plot_rolling_overlay(
360
- df_plot, date_col, active_y,
361
- window=window_size,
362
- title=f"Rolling {window_size}-pt Mean – {active_y}",
363
- style_dict=style_dict, palette_colors=palette_colors,
364
- )
365
 
366
- elif chart_type == "Year-over-Year Change":
367
- yoy_result = compute_yoy_change(df_plot, date_col, active_y)
368
- yoy_df = pd.DataFrame({
369
- "date": yoy_result[date_col],
370
- "abs_change": yoy_result["yoy_abs_change"],
371
- "pct_change": yoy_result["yoy_pct_change"],
372
- }).dropna()
373
- fig = plot_yoy_change(
374
- df_plot, date_col, active_y, yoy_df,
375
- title=f"Year-over-Year Change – {active_y}",
376
- style_dict=style_dict,
377
- )
378
 
379
- elif chart_type == "Lag Plot":
380
- fig = plot_lag(
381
- df_plot[active_y],
382
- lag=lag_val,
383
- title=f"Lag-{lag_val} Plot – {active_y}",
384
- style_dict=style_dict,
385
- )
 
 
 
386
 
387
- except Exception as exc:
388
- st.error(f"Chart error: {exc}")
389
 
390
- if fig is not None:
391
- st.pyplot(fig, width="stretch")
 
 
 
 
392
 
393
- st.session_state["_single_df_plot"] = df_plot
394
- st.session_state["_single_fig"] = fig
395
- st.session_state["_single_active_y"] = active_y
396
- st.session_state["_single_chart_type"] = chart_type
 
397
 
398
 
399
  @st.fragment
@@ -402,16 +413,14 @@ def _single_insights_fragment(freq_info, date_col):
402
  active_y = st.session_state.get("_single_active_y")
403
  chart_type = st.session_state.get("_single_chart_type")
404
  fig = st.session_state.get("_single_fig")
 
405
 
406
- if df_plot is None or active_y is None:
407
  return
408
 
409
- # ---- Summary stats expander -------------------------------------------
410
  with st.expander("Summary Statistics", expanded=False):
411
- stats = compute_summary_stats(df_plot, date_col, active_y)
412
  _render_summary_stats(stats)
413
 
414
- # ---- AI Interpretation ------------------------------------------------
415
  _render_ai_interpretation(
416
  fig, chart_type, freq_info, df_plot, date_col, active_y, "interpret_a",
417
  )
@@ -422,6 +431,7 @@ def _panel_chart_fragment(working_df, date_col, y_cols, style_dict):
422
  if len(y_cols) < 2:
423
  st.info("Select 2+ value columns in the sidebar to use panel plots.")
424
  st.session_state["_panel_fig"] = None
 
425
  return
426
 
427
  st.subheader("Panel Plot (Small Multiples)")
@@ -429,42 +439,52 @@ def _panel_chart_fragment(working_df, date_col, y_cols, style_dict):
429
  if "panel_cols" not in st.session_state:
430
  st.session_state["panel_cols"] = y_cols[:4]
431
  else:
432
- st.session_state["panel_cols"] = [
433
- c for c in st.session_state["panel_cols"] if c in y_cols
434
- ]
435
- panel_cols = st.multiselect("Columns to plot", y_cols, key="panel_cols")
436
 
437
- if panel_cols:
438
  pc1, pc2 = st.columns(2)
439
  with pc1:
440
- panel_chart = st.selectbox(
441
- "Chart type", ["line", "bar"], key="panel_chart"
442
- )
443
  with pc2:
444
  if "panel_shared" not in st.session_state:
445
  st.session_state["panel_shared"] = True
446
  shared_y = st.checkbox("Shared Y axis", key="panel_shared")
447
 
448
  palette_name_b = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_b")
449
- palette_b = get_palette_colors(palette_name_b, len(panel_cols))
450
 
 
 
 
 
451
  fig_panel = None
452
- try:
453
- fig_panel = plot_panel(
454
- working_df, date_col, panel_cols,
455
- chart_type=panel_chart,
456
- shared_y=shared_y,
457
- title="Panel Comparison",
458
- style_dict=style_dict,
459
- palette_colors=palette_b,
460
- )
461
- st.pyplot(fig_panel, width="stretch")
462
- except Exception as exc:
463
- st.error(f"Panel chart error: {exc}")
 
 
 
464
 
 
465
  st.session_state["_panel_fig"] = fig_panel
 
 
 
 
 
466
  else:
467
- st.session_state["_panel_fig"] = None
468
 
469
 
470
  @st.fragment
@@ -472,15 +492,12 @@ def _panel_insights_fragment(working_df, date_col, freq_info):
472
  panel_cols = st.session_state.get("panel_cols") or []
473
  fig_panel = st.session_state.get("_panel_fig")
474
  panel_chart = st.session_state.get("panel_chart", "line")
 
475
 
476
- if not panel_cols:
477
  return
478
 
479
- # Per-series summary table
480
  with st.expander("Per-series Summary", expanded=False):
481
- summary_df = compute_multi_series_summary(
482
- working_df, date_col, panel_cols,
483
- )
484
  st.dataframe(
485
  summary_df.style.format({
486
  "mean": "{:,.2f}",
@@ -493,7 +510,6 @@ def _panel_insights_fragment(working_df, date_col, freq_info):
493
  width="stretch",
494
  )
495
 
496
- # AI Interpretation
497
  _render_ai_interpretation(
498
  fig_panel, f"Panel ({panel_chart})", freq_info,
499
  working_df, date_col, ", ".join(panel_cols), "interpret_b",
@@ -505,6 +521,7 @@ def _spaghetti_chart_fragment(working_df, date_col, y_cols, style_dict):
505
  if len(y_cols) < 2:
506
  st.info("Select 2+ value columns in the sidebar to use spaghetti plots.")
507
  st.session_state["_spag_fig"] = None
 
508
  return
509
 
510
  st.subheader("Spaghetti Plot")
@@ -512,12 +529,11 @@ def _spaghetti_chart_fragment(working_df, date_col, y_cols, style_dict):
512
  if "spag_cols" not in st.session_state:
513
  st.session_state["spag_cols"] = list(y_cols)
514
  else:
515
- st.session_state["spag_cols"] = [
516
- c for c in st.session_state["spag_cols"] if c in y_cols
517
- ]
518
- spag_cols = st.multiselect("Columns to include", y_cols, key="spag_cols")
519
 
520
- if spag_cols:
521
  sc1, sc2, sc3 = st.columns(3)
522
  with sc1:
523
  alpha_val = st.slider("Alpha", 0.05, 1.0, 0.15, 0.05, key="spag_alpha")
@@ -533,44 +549,56 @@ def _spaghetti_chart_fragment(working_df, date_col, y_cols, style_dict):
533
  highlight_col = highlight if highlight != "(none)" else None
534
 
535
  show_median = st.checkbox("Show Median + IQR band", key="spag_median")
536
-
537
  palette_name_c = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_c")
538
- palette_c = get_palette_colors(palette_name_c, len(spag_cols))
 
 
 
 
 
 
539
 
 
540
  fig_spag = None
541
- try:
542
- fig_spag = plot_spaghetti(
543
- working_df, date_col, spag_cols,
544
- alpha=alpha_val,
545
- highlight_col=highlight_col,
546
- top_n=top_n,
547
- show_median_band=show_median,
548
- title="Spaghetti Plot",
549
- style_dict=style_dict,
550
- palette_colors=palette_c,
551
- )
552
- st.pyplot(fig_spag, width="stretch")
553
- except Exception as exc:
554
- st.error(f"Spaghetti chart error: {exc}")
 
 
 
555
 
 
556
  st.session_state["_spag_fig"] = fig_spag
 
 
 
 
 
557
  else:
558
- st.session_state["_spag_fig"] = None
559
 
560
 
561
  @st.fragment
562
  def _spaghetti_insights_fragment(working_df, date_col, freq_info):
563
  spag_cols = st.session_state.get("spag_cols") or []
564
  fig_spag = st.session_state.get("_spag_fig")
 
565
 
566
- if not spag_cols:
567
  return
568
 
569
- # Per-series summary table
570
  with st.expander("Per-series Summary", expanded=False):
571
- spag_summary = compute_multi_series_summary(
572
- working_df, date_col, spag_cols,
573
- )
574
  st.dataframe(
575
  spag_summary.style.format({
576
  "mean": "{:,.2f}",
@@ -583,7 +611,6 @@ def _spaghetti_insights_fragment(working_df, date_col, freq_info):
583
  width="stretch",
584
  )
585
 
586
- # AI Interpretation
587
  _render_ai_interpretation(
588
  fig_spag, "Spaghetti Plot", freq_info,
589
  working_df, date_col, ", ".join(spag_cols), "interpret_c",
 
106
  "tab_a_y", "dr_mode", "dr_n", "dr_custom",
107
  "chart_type_a", "pal_a", "color_by_a", "period_a", "window_a", "lag_a", "decomp_a",
108
  "_single_df_plot", "_single_fig", "_single_active_y", "_single_chart_type",
109
+ "_single_input_key", "_single_stats",
110
  "panel_cols", "panel_chart", "panel_shared", "pal_b", "_panel_fig",
111
+ "_panel_input_key", "_panel_summary_df",
112
  "spag_cols", "spag_alpha", "spag_topn", "spag_highlight", "spag_median", "pal_c", "_spag_fig",
113
+ "_spag_input_key", "_spag_summary_df",
114
  ]
115
 
116
 
 
211
  @st.fragment
212
  def _single_chart_fragment(working_df, date_col, y_cols, freq_info, style_dict):
213
  if len(y_cols) == 1:
214
+ st.session_state["tab_a_y"] = y_cols[0]
215
+ elif st.session_state.get("tab_a_y") not in y_cols:
216
+ st.session_state["tab_a_y"] = y_cols[0]
217
+
218
+ with st.form("single_chart_form", border=False):
219
+ if len(y_cols) == 1:
220
+ active_y = y_cols[0]
221
+ st.caption(f"Value column: `{active_y}`")
222
+ else:
223
+ active_y = st.selectbox("Select value column", y_cols, key="tab_a_y")
224
+
225
+ dr_mode = st.radio(
226
+ "Date range",
227
+ ["All", "Last N years", "Custom"],
228
+ horizontal=True,
229
+ key="dr_mode",
230
+ )
231
 
232
+ df_plot = working_df.copy()
233
+ n_years = st.session_state.get("dr_n", 5)
234
+ sel = st.session_state.get("dr_custom")
235
+
236
+ if dr_mode == "Last N years":
237
+ n_years = st.slider("Years", 1, 20, 5, key="dr_n")
238
+ cutoff = df_plot[date_col].max() - pd.DateOffset(years=n_years)
239
+ df_plot = df_plot[df_plot[date_col] >= cutoff]
240
+ elif dr_mode == "Custom":
241
+ d_min = df_plot[date_col].min().date()
242
+ d_max = df_plot[date_col].max().date()
243
+ sel = st.slider("Date range", d_min, d_max, (d_min, d_max), key="dr_custom")
244
+ df_plot = df_plot[
245
+ (df_plot[date_col].dt.date >= sel[0])
246
+ & (df_plot[date_col].dt.date <= sel[1])
247
+ ]
 
 
 
 
 
 
 
 
 
 
 
 
248
 
 
 
 
249
  chart_type = st.selectbox("Chart type", _CHART_TYPES, key="chart_type_a")
 
250
  palette_name = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_a")
251
+ palette_colors = get_palette_colors(palette_name, max(12, len(y_cols)))
 
252
  swatch_fig = render_palette_preview(palette_colors[:8])
253
  st.pyplot(swatch_fig, width="stretch")
254
 
 
255
  color_by = None
256
+ if "Colored Markers" in chart_type:
257
  if "month" in working_df.columns:
258
  color_by = st.selectbox(
259
  "Color by",
 
261
  key="color_by_a",
262
  )
263
  else:
264
+ other_cols = [c for c in working_df.columns if c not in (date_col, active_y)][:5]
 
 
 
265
  if other_cols:
266
+ color_by = st.selectbox("Color by", other_cols, key="color_by_a")
 
 
267
 
 
268
  period_label = "month"
269
  window_size = 12
270
  lag_val = 1
 
272
 
273
  if chart_type in ("Seasonal Plot", "Seasonal Sub-series"):
274
  period_label = st.selectbox("Period", ["month", "quarter"], key="period_a")
 
275
  if chart_type == "Rolling Mean Overlay":
276
  window_size = st.slider("Window", 2, 52, 12, key="window_a")
 
277
  if chart_type == "Lag Plot":
278
  lag_val = st.slider("Lag", 1, 52, 1, key="lag_a")
 
279
  if chart_type == "Decomposition":
280
  decomp_model = st.selectbox("Model", ["additive", "multiplicative"], key="decomp_a")
281
 
282
+ update_single = st.form_submit_button("Update chart", use_container_width=True)
283
+
284
+ input_key = (
285
+ _df_hash(working_df), active_y, dr_mode, n_years, sel,
286
+ chart_type, palette_name, color_by, period_label, window_size, lag_val, decomp_model,
287
+ freq_info.label if freq_info else None,
288
+ )
289
+ should_compute = update_single or st.session_state.get("_single_fig") is None
290
+
291
+ if should_compute:
292
  fig = None
293
+ stats = None
294
+
295
+ if df_plot.empty:
296
+ st.warning("No data in selected range.")
297
+ else:
298
+ try:
299
+ if chart_type == "Line with Markers":
300
+ fig = plot_line_with_markers(
301
+ df_plot, date_col, active_y,
302
+ title=f"{active_y} over Time",
303
+ style_dict=style_dict, palette_colors=palette_colors,
304
+ )
305
 
306
+ elif "Colored Markers" in chart_type and color_by is not None:
307
+ fig = plot_line_colored_markers(
308
+ df_plot, date_col, active_y,
309
+ color_by=color_by, palette_colors=palette_colors,
310
+ title=f"{active_y} colored by {color_by}",
311
+ style_dict=style_dict,
312
+ )
313
 
314
+ elif chart_type == "Seasonal Plot":
315
+ fig = plot_seasonal(
316
+ df_plot, date_col, active_y,
317
+ period=period_label,
318
+ palette_name_colors=palette_colors,
319
+ title=f"Seasonal Plot - {active_y}",
320
+ style_dict=style_dict,
321
+ )
322
 
323
+ elif chart_type == "Seasonal Sub-series":
324
+ fig = plot_seasonal_subseries(
325
+ df_plot, date_col, active_y,
326
+ period=period_label,
327
+ title=f"Seasonal Sub-series - {active_y}",
328
+ style_dict=style_dict, palette_colors=palette_colors,
329
+ )
330
 
331
+ elif chart_type == "ACF / PACF":
332
+ series = df_plot[active_y].dropna()
333
+ acf_vals, acf_ci, pacf_vals, pacf_ci = compute_acf_pacf(series)
334
+ fig = plot_acf_pacf(
335
+ acf_vals, acf_ci, pacf_vals, pacf_ci,
336
+ title=f"ACF / PACF - {active_y}",
337
+ style_dict=style_dict,
338
+ )
339
 
340
+ elif chart_type == "Decomposition":
341
+ period_int = None
342
+ if freq_info and freq_info.label == "Monthly":
343
+ period_int = 12
344
+ elif freq_info and freq_info.label == "Quarterly":
345
+ period_int = 4
346
+ elif freq_info and freq_info.label == "Weekly":
347
+ period_int = 52
348
+ elif freq_info and freq_info.label == "Daily":
349
+ period_int = 365
350
+
351
+ result = compute_decomposition(
352
+ df_plot, date_col, active_y,
353
+ model=decomp_model, period=period_int,
354
+ )
355
+ fig = plot_decomposition(
356
+ result,
357
+ title=f"Decomposition - {active_y} ({decomp_model})",
358
+ style_dict=style_dict,
359
+ )
360
 
361
+ elif chart_type == "Rolling Mean Overlay":
362
+ fig = plot_rolling_overlay(
363
+ df_plot, date_col, active_y,
364
+ window=window_size,
365
+ title=f"Rolling {window_size}-pt Mean - {active_y}",
366
+ style_dict=style_dict, palette_colors=palette_colors,
367
+ )
368
 
369
+ elif chart_type == "Year-over-Year Change":
370
+ yoy_result = compute_yoy_change(df_plot, date_col, active_y)
371
+ yoy_df = pd.DataFrame({
372
+ "date": yoy_result[date_col],
373
+ "abs_change": yoy_result["yoy_abs_change"],
374
+ "pct_change": yoy_result["yoy_pct_change"],
375
+ }).dropna()
376
+ fig = plot_yoy_change(
377
+ df_plot, date_col, active_y, yoy_df,
378
+ title=f"Year-over-Year Change - {active_y}",
379
+ style_dict=style_dict,
380
+ )
381
 
382
+ elif chart_type == "Lag Plot":
383
+ fig = plot_lag(
384
+ df_plot[active_y],
385
+ lag=lag_val,
386
+ title=f"Lag-{lag_val} Plot - {active_y}",
387
+ style_dict=style_dict,
388
+ )
389
+
390
+ except Exception as exc:
391
+ st.error(f"Chart error: {exc}")
392
 
393
+ if fig is not None:
394
+ stats = compute_summary_stats(df_plot, date_col, active_y)
395
 
396
+ st.session_state["_single_input_key"] = input_key
397
+ st.session_state["_single_df_plot"] = df_plot if not df_plot.empty else None
398
+ st.session_state["_single_fig"] = fig
399
+ st.session_state["_single_active_y"] = active_y if not df_plot.empty else None
400
+ st.session_state["_single_chart_type"] = chart_type if not df_plot.empty else None
401
+ st.session_state["_single_stats"] = stats
402
 
403
+ fig = st.session_state.get("_single_fig")
404
+ if fig is not None:
405
+ st.pyplot(fig, width="stretch")
406
+ else:
407
+ st.info("Choose options above, then click `Update chart`.")
408
 
409
 
410
  @st.fragment
 
413
  active_y = st.session_state.get("_single_active_y")
414
  chart_type = st.session_state.get("_single_chart_type")
415
  fig = st.session_state.get("_single_fig")
416
+ stats = st.session_state.get("_single_stats")
417
 
418
+ if df_plot is None or active_y is None or stats is None:
419
  return
420
 
 
421
  with st.expander("Summary Statistics", expanded=False):
 
422
  _render_summary_stats(stats)
423
 
 
424
  _render_ai_interpretation(
425
  fig, chart_type, freq_info, df_plot, date_col, active_y, "interpret_a",
426
  )
 
431
  if len(y_cols) < 2:
432
  st.info("Select 2+ value columns in the sidebar to use panel plots.")
433
  st.session_state["_panel_fig"] = None
434
+ st.session_state["_panel_summary_df"] = None
435
  return
436
 
437
  st.subheader("Panel Plot (Small Multiples)")
 
439
  if "panel_cols" not in st.session_state:
440
  st.session_state["panel_cols"] = y_cols[:4]
441
  else:
442
+ st.session_state["panel_cols"] = [c for c in st.session_state["panel_cols"] if c in y_cols]
443
+
444
+ with st.form("panel_chart_form", border=False):
445
+ panel_cols = st.multiselect("Columns to plot", y_cols, key="panel_cols")
446
 
 
447
  pc1, pc2 = st.columns(2)
448
  with pc1:
449
+ panel_chart = st.selectbox("Chart type", ["line", "bar"], key="panel_chart")
 
 
450
  with pc2:
451
  if "panel_shared" not in st.session_state:
452
  st.session_state["panel_shared"] = True
453
  shared_y = st.checkbox("Shared Y axis", key="panel_shared")
454
 
455
  palette_name_b = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_b")
456
+ update_panel = st.form_submit_button("Update chart", use_container_width=True)
457
 
458
+ input_key = (_df_hash(working_df), tuple(panel_cols), panel_chart, shared_y, palette_name_b)
459
+ should_compute = update_panel or st.session_state.get("_panel_fig") is None
460
+
461
+ if should_compute:
462
  fig_panel = None
463
+ summary_df = None
464
+ if panel_cols:
465
+ palette_b = get_palette_colors(palette_name_b, len(panel_cols))
466
+ try:
467
+ fig_panel = plot_panel(
468
+ working_df, date_col, panel_cols,
469
+ chart_type=panel_chart,
470
+ shared_y=shared_y,
471
+ title="Panel Comparison",
472
+ style_dict=style_dict,
473
+ palette_colors=palette_b,
474
+ )
475
+ summary_df = compute_multi_series_summary(working_df, date_col, panel_cols)
476
+ except Exception as exc:
477
+ st.error(f"Panel chart error: {exc}")
478
 
479
+ st.session_state["_panel_input_key"] = input_key
480
  st.session_state["_panel_fig"] = fig_panel
481
+ st.session_state["_panel_summary_df"] = summary_df
482
+
483
+ fig_panel = st.session_state.get("_panel_fig")
484
+ if fig_panel is not None:
485
+ st.pyplot(fig_panel, width="stretch")
486
  else:
487
+ st.info("Choose panel options above, then click `Update chart`.")
488
 
489
 
490
  @st.fragment
 
492
  panel_cols = st.session_state.get("panel_cols") or []
493
  fig_panel = st.session_state.get("_panel_fig")
494
  panel_chart = st.session_state.get("panel_chart", "line")
495
+ summary_df = st.session_state.get("_panel_summary_df")
496
 
497
+ if not panel_cols or fig_panel is None or summary_df is None:
498
  return
499
 
 
500
  with st.expander("Per-series Summary", expanded=False):
 
 
 
501
  st.dataframe(
502
  summary_df.style.format({
503
  "mean": "{:,.2f}",
 
510
  width="stretch",
511
  )
512
 
 
513
  _render_ai_interpretation(
514
  fig_panel, f"Panel ({panel_chart})", freq_info,
515
  working_df, date_col, ", ".join(panel_cols), "interpret_b",
 
521
  if len(y_cols) < 2:
522
  st.info("Select 2+ value columns in the sidebar to use spaghetti plots.")
523
  st.session_state["_spag_fig"] = None
524
+ st.session_state["_spag_summary_df"] = None
525
  return
526
 
527
  st.subheader("Spaghetti Plot")
 
529
  if "spag_cols" not in st.session_state:
530
  st.session_state["spag_cols"] = list(y_cols)
531
  else:
532
+ st.session_state["spag_cols"] = [c for c in st.session_state["spag_cols"] if c in y_cols]
533
+
534
+ with st.form("spag_chart_form", border=False):
535
+ spag_cols = st.multiselect("Columns to include", y_cols, key="spag_cols")
536
 
 
537
  sc1, sc2, sc3 = st.columns(3)
538
  with sc1:
539
  alpha_val = st.slider("Alpha", 0.05, 1.0, 0.15, 0.05, key="spag_alpha")
 
549
  highlight_col = highlight if highlight != "(none)" else None
550
 
551
  show_median = st.checkbox("Show Median + IQR band", key="spag_median")
 
552
  palette_name_c = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_c")
553
+ update_spag = st.form_submit_button("Update chart", use_container_width=True)
554
+
555
+ input_key = (
556
+ _df_hash(working_df), tuple(spag_cols), alpha_val, top_n, highlight_col,
557
+ show_median, palette_name_c,
558
+ )
559
+ should_compute = update_spag or st.session_state.get("_spag_fig") is None
560
 
561
+ if should_compute:
562
  fig_spag = None
563
+ spag_summary = None
564
+ if spag_cols:
565
+ palette_c = get_palette_colors(palette_name_c, len(spag_cols))
566
+ try:
567
+ fig_spag = plot_spaghetti(
568
+ working_df, date_col, spag_cols,
569
+ alpha=alpha_val,
570
+ highlight_col=highlight_col,
571
+ top_n=top_n,
572
+ show_median_band=show_median,
573
+ title="Spaghetti Plot",
574
+ style_dict=style_dict,
575
+ palette_colors=palette_c,
576
+ )
577
+ spag_summary = compute_multi_series_summary(working_df, date_col, spag_cols)
578
+ except Exception as exc:
579
+ st.error(f"Spaghetti chart error: {exc}")
580
 
581
+ st.session_state["_spag_input_key"] = input_key
582
  st.session_state["_spag_fig"] = fig_spag
583
+ st.session_state["_spag_summary_df"] = spag_summary
584
+
585
+ fig_spag = st.session_state.get("_spag_fig")
586
+ if fig_spag is not None:
587
+ st.pyplot(fig_spag, width="stretch")
588
  else:
589
+ st.info("Choose spaghetti options above, then click `Update chart`.")
590
 
591
 
592
  @st.fragment
593
  def _spaghetti_insights_fragment(working_df, date_col, freq_info):
594
  spag_cols = st.session_state.get("spag_cols") or []
595
  fig_spag = st.session_state.get("_spag_fig")
596
+ spag_summary = st.session_state.get("_spag_summary_df")
597
 
598
+ if not spag_cols or fig_spag is None or spag_summary is None:
599
  return
600
 
 
601
  with st.expander("Per-series Summary", expanded=False):
 
 
 
602
  st.dataframe(
603
  spag_summary.style.format({
604
  "mean": "{:,.2f}",
 
611
  width="stretch",
612
  )
613
 
 
614
  _render_ai_interpretation(
615
  fig_spag, "Spaghetti Plot", freq_info,
616
  working_df, date_col, ", ".join(spag_cols), "interpret_c",