apmtools 0.2.45__tar.gz → 0.2.46__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {apmtools-0.2.45 → apmtools-0.2.46}/PKG-INFO +1 -1
  2. {apmtools-0.2.45 → apmtools-0.2.46}/pyproject.toml +1 -1
  3. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/appearance.py +2 -4
  4. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/data.py +10 -6
  5. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/matplotlib_renderer.py +45 -6
  6. apmtools-0.2.46/src/apmtools/shiny/multi_y.py +56 -0
  7. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/plotly_renderer.py +83 -4
  8. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/ui.py +458 -40
  9. {apmtools-0.2.45 → apmtools-0.2.46}/.gitignore +0 -0
  10. {apmtools-0.2.45 → apmtools-0.2.46}/LICENSE +0 -0
  11. {apmtools-0.2.45 → apmtools-0.2.46}/README.md +0 -0
  12. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/__init__.py +0 -0
  13. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/classes.py +0 -0
  14. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/data_processing.py +0 -0
  15. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/functions.py +0 -0
  16. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/legacy.py +0 -0
  17. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/__init__.py +0 -0
  18. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/columns.py +0 -0
  19. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/config.py +0 -0
  20. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/export.py +0 -0
  21. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/summary.py +0 -0
  22. {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.45
3
+ Version: 0.2.46
4
4
  Summary: A collection of tools for processing air pollution monitoring data
5
5
  Project-URL: Homepage, https://github.com/federlorenz/apmtools
6
6
  Author-email: Federico Lorenzetti <lorenzetti.federico@gmail.com>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apmtools"
7
- version = "0.2.45"
7
+ version = "0.2.46"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -41,9 +41,7 @@ def _label_plot_axes(fig, plot_type, x_column, y_columns, *,
41
41
  fig.update_yaxes(title_text=_axis_label(y_columns[0]))
42
42
  elif plot_type == "line" and len(y_columns) > 1:
43
43
  fig.update_xaxes(title_text=_axis_label(x_column))
44
- # The values axis is shared by multiple series; facet headings carry
45
- # the variable-specific unit rather than misleadingly naming one unit.
46
- fig.update_yaxes(title_text="Value")
44
+ fig.update_yaxes(title_text="<br>".join(_axis_label(y) for y in y_columns))
47
45
  for annotation in fig.layout.annotations:
48
46
  label = str(annotation.text)
49
47
  if label.startswith("variable="):
@@ -125,7 +123,7 @@ def _apply_plot_appearance(fig, *, title, x_label="", y_label="",
125
123
  if x_label.strip():
126
124
  fig.update_xaxes(title_text=x_label.strip())
127
125
  if y_label.strip():
128
- fig.update_yaxes(title_text=y_label.strip())
126
+ fig.update_yaxes(title_text=y_label.strip().replace("\n", "<br>"))
129
127
  fig.update_xaxes(showgrid=show_grid)
130
128
  fig.update_yaxes(showgrid=show_grid)
131
129
  use_x, use_y = _numeric_axis_controls(plot_kind, box_orientation)
@@ -23,7 +23,8 @@ def _composite_labels(data: pd.DataFrame, columns: list[str]) -> pd.Series:
23
23
  return "None" if pd.isna(value) else str(value)
24
24
  if len(columns) == 1:
25
25
  return data[columns[0]].map(label)
26
- return pd.Series((" | ".join(label(value) for value in row)
26
+ return pd.Series(
27
+ (" | ".join(label(value) for value in row)
27
28
  for row in data[columns].itertuples(index=False, name=None)),
28
29
  index=data.index,
29
30
  dtype="str",
@@ -130,8 +131,12 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate, *,
130
131
  is selected, matching Summary.group_stat(frequency=None, grouping_by=...).
131
132
  This function is called only when aggregation is explicitly requested.
132
133
  """
134
+ if data is None or data.empty:
135
+ return pd.DataFrame()
133
136
  if frequency is None and not grouping_by:
134
- raise ValueError("Select at least one categorical grouping variable for categorical-only aggregation.")
137
+ # Invalid/incomplete UI selection: no aggregation groups exist.
138
+ # Return empty rather than breaking Shiny's reactive graph.
139
+ return pd.DataFrame()
135
140
 
136
141
  result = data.group_stat(
137
142
  value_cols=value_columns,
@@ -142,10 +147,9 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate, *,
142
147
  if frequency is not None else {}),
143
148
  )
144
149
  if result is None:
145
- raise ValueError(
146
- "Summary.group_stat() returned None. Check the selected variables, "
147
- "grouping columns, frequency, and completeness settings."
148
- )
150
+ # group_stat() may return None when no rows survive completeness
151
+ # requirements or other filtering. Treat this as an empty plot.
152
+ return pd.DataFrame()
149
153
  return result
150
154
 
151
155
 
@@ -27,7 +27,7 @@ def _matplotlib_defaults(spec, *, palette="Default"):
27
27
  x_label = (" | ".join(_axis_label(c) for c in spec["categories"])
28
28
  if kind in ("box", "bar") else _axis_label(x))
29
29
  y_label = ("Probability density" if kind == "histogram" else
30
- "Value" if kind == "line" and len(ys) > 1 else
30
+ "\n".join(_axis_label(y) for y in ys) if kind == "line" and len(ys) > 1 else
31
31
  _axis_label(ys[0]) if ys else "Count")
32
32
  x_label = spec.get("x_label_override") or x_label
33
33
  return dict(x_label=x_label, y_label=y_label, names=names, colors=colors)
@@ -322,7 +322,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
322
322
  legend_labels="", trace_colors="", palette="Default",
323
323
  x_min="", x_max="", y_min="", y_max="",
324
324
  template="plotly", legend_position="right",
325
- legend_inside=False, legend_columns=1, show_grid=True, font_size=12,
325
+ legend_inside=False, legend_columns=1, multi_y_color_by="group", show_grid=True, font_size=12,
326
326
  line_width=2, marker_size=7, line_alpha="", line_style="",
327
327
  scatter_size="", scatter_marker="", scatter_alpha="", hist_alpha="",
328
328
  box_showfliers=True, box_orientation="vertical",
@@ -376,7 +376,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
376
376
  x_min=_panel_limit_value(x_min, panel_title), x_max=_panel_limit_value(x_max, panel_title),
377
377
  y_min=_panel_limit_value(y_min, panel_title), y_max=_panel_limit_value(y_max, panel_title),
378
378
  template=template, legend_position=legend_position,
379
- legend_inside=legend_inside, legend_columns=legend_columns, show_grid=show_grid, font_size=font_size,
379
+ legend_inside=legend_inside, legend_columns=legend_columns, multi_y_color_by=multi_y_color_by, show_grid=show_grid, font_size=font_size,
380
380
  line_width=line_width, marker_size=marker_size, line_alpha=line_alpha, line_style=line_style,
381
381
  scatter_size=scatter_size, scatter_marker=scatter_marker, scatter_alpha=scatter_alpha,
382
382
  hist_alpha=hist_alpha, box_showfliers=box_showfliers, box_orientation=box_orientation,
@@ -413,6 +413,11 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
413
413
  labels = defaults["names"] if group_col else ([str(y) for y in ys] if len(ys) > 1 else ["All observations"])
414
414
  colors = {name: overrides.get(name, defaults["colors"].get(name, palette_values[i % len(palette_values)]))
415
415
  for i, name in enumerate(labels)}
416
+ multi_y = kind == "line" and len(ys) > 1
417
+ if multi_y:
418
+ from .multi_y import multi_y_styles
419
+ multi_colors, multi_dashes, variable_names, group_names = multi_y_styles(
420
+ ys, labels if group_col else [], palette_values, multi_y_color_by, overrides, styles)
416
421
  for name, color in list(colors.items()):
417
422
  if not mcolors.is_color_like(color):
418
423
  colors[name] = defaults["colors"].get(name, palette_values[0])
@@ -480,11 +485,12 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
480
485
  parts = (rows.groupby(segment_col, dropna=False, sort=False) if segment_col else [(None, rows)])
481
486
  for _, part in parts:
482
487
  part = part.sort_values(x)
483
- ax.plot(part[x], part[y], color=colors.get(name, palette_values[0]),
488
+ key = (str(y), str(name) if group_col else None)
489
+ ax.plot(part[x], part[y], color=(multi_colors[key] if multi_y else colors.get(name, palette_values[0])),
484
490
  linewidth=float(line_width),
485
- linestyle=_line_style(styles, name),
491
+ linestyle=(multi_dashes[key] if multi_y else _line_style(styles, name)),
486
492
  alpha=_bounded_float(alphas, name, 1, 0, 1),
487
- label=rename.get(name, name))
493
+ label=("_nolegend_" if multi_y else rename.get(name, name)))
488
494
  elif kind in ("box", "bar"):
489
495
  # Build categorical groups in a single pass. The former implementation
490
496
  # repeatedly scanned the full DataFrame for every category × group,
@@ -593,6 +599,39 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
593
599
  unique = dict(zip(texts, handles))
594
600
  if unique and not spec.get("_hide_ungrouped_legend", not bool(spec["groups"])):
595
601
  ax.legend(unique.values(), unique.keys(), title="Legend")
602
+ if multi_y:
603
+ from matplotlib.lines import Line2D
604
+ # Independent legend keys for the two dimensions, with explicit headings.
605
+ legend_handles = []
606
+ legend_labels_out = []
607
+ if multi_y_color_by == "none":
608
+ for group in group_names if group_col else []:
609
+ key = (variable_names[0], group)
610
+ legend_handles.append(Line2D([], [], color=multi_colors[key], linestyle=multi_dashes[key], linewidth=line_width))
611
+ legend_labels_out.append(rename.get(group, group))
612
+ if multi_y_color_by == "both":
613
+ for group in (group_names or [None]):
614
+ for variable in variable_names:
615
+ key = (variable, group)
616
+ label = f"{group}, {variable}" if group is not None else variable
617
+ legend_handles.append(Line2D([], [], color=multi_colors[key], linestyle=multi_dashes[key], linewidth=line_width))
618
+ legend_labels_out.append(rename.get(label, label))
619
+ added_section = False
620
+ for heading, keys, by_var in (() if multi_y_color_by in ("both", "none") else (("Y variable", variable_names, True), ("Grouping", group_names, False))):
621
+ if not keys or (not by_var and not group_col):
622
+ continue
623
+ if added_section:
624
+ legend_handles.append(Line2D([], [], color="none"))
625
+ legend_labels_out.append(" ")
626
+ added_section = True
627
+ legend_handles.append(Line2D([], [], color="none"))
628
+ legend_labels_out.append(heading)
629
+ for value in keys:
630
+ key = (value, group_names[0] if group_col else None) if by_var else (variable_names[0], value)
631
+ legend_handles.append(Line2D([], [], color=multi_colors[key], linestyle=multi_dashes[key], linewidth=line_width))
632
+ legend_labels_out.append(rename.get(value, value))
633
+ if legend_handles:
634
+ ax.legend(legend_handles, legend_labels_out, title=None)
596
635
  legend = ax.get_legend()
597
636
  if legend is not None:
598
637
  locs = {"right":"upper right", "left":"upper left", "top":"upper center", "bottom":"lower center"}
@@ -0,0 +1,56 @@
1
+ """Independent categorical colour and line-style encodings for multi-Y lines."""
2
+
3
+
4
+ def multi_y_styles(variables, groups, palette, mode="group", colors=None, styles=None):
5
+ variables = [str(v) for v in variables]
6
+ groups = [str(g) for g in groups] if groups else []
7
+ colors = colors or {}
8
+ styles = styles or {}
9
+ palette = list(palette) or ["#636EFA", "#EF553B", "#00CC96"]
10
+ combinations = [(var, group) for group in (groups or [None]) for var in variables]
11
+ if mode == "none":
12
+ # Restore original single-Y group encoding across all Y variables.
13
+ color_map = {}
14
+ dash_map = {}
15
+ for var in variables:
16
+ for group in (groups or [None]):
17
+ idx = groups.index(group) if group is not None else 0
18
+ color_map[(var, group)] = colors.get(group, palette[idx % len(palette)]) if group is not None else palette[0]
19
+ style = styles.get(group, "-") if group is not None else "-"
20
+ dash_map[(var, group)] = {"solid":"-", "dashed":"--", "dotted":":", "dashdot":"-."}.get(style, style)
21
+ return color_map, dash_map, variables, groups
22
+ primary = groups if mode == "group" and groups else variables
23
+ secondary = variables if mode == "group" and groups else groups
24
+ dash_values = ["-", "--", ":", "-."]
25
+ color_map, dash_map = {}, {}
26
+ for var in variables:
27
+ for group in (groups or [None]):
28
+ p = group if mode == "group" and groups else var
29
+ q = var if mode == "group" and groups else group
30
+ if mode == "both":
31
+ index = combinations.index((var, group))
32
+ if index < len(palette):
33
+ default_color = palette[index]
34
+ else:
35
+ import colorsys
36
+ hue = (index * 0.618033988749895) % 1.0
37
+ rgb = colorsys.hsv_to_rgb(hue, 0.72, 0.88)
38
+ default_color = "#{:02x}{:02x}{:02x}".format(*(round(255 * c) for c in rgb))
39
+ combined = f"{group}, {var}" if group is not None else var
40
+ color_map[(var, group)] = colors.get(combined, default_color)
41
+ dash_map[(var, group)] = styles.get(combined, "-")
42
+ continue
43
+ color_map[(var, group)] = colors.get(p, colors.get(f"{group}, {var}", palette[primary.index(p) % len(palette)]))
44
+ # For colour-by-variable, the grouping dimension must control dashes.
45
+ # Do not let a variable-level style override erase that distinction.
46
+ default_dash = dash_values[secondary.index(q) % len(dash_values)] if q in secondary else "-"
47
+ # The Shiny appearance panel automatically populates each group with
48
+ # "solid". Those generated defaults must not collapse the
49
+ # group-specific dash cycle in colour-by-variable mode.
50
+ override = styles.get(q)
51
+ if mode == "variable" and groups and override in ("solid", "-"):
52
+ override = None
53
+ dash_map[(var, group)] = override if override is not None else default_dash
54
+ if dash_map[(var, group)] in ("solid", "dashed", "dotted", "dashdot"):
55
+ dash_map[(var, group)] = {"solid":"-", "dashed":"--", "dotted":":", "dashdot":"-."}[dash_map[(var, group)]]
56
+ return color_map, dash_map, variables, groups
@@ -228,7 +228,7 @@ def _lineplot_figure(plot_df, x_column, y_columns, *, group_columns, frequency,
228
228
  y="value",
229
229
  color=group_column,
230
230
  line_group=line_group_column,
231
- facet_row="variable" if len(y_columns) > 1 else None,
231
+ line_dash="variable" if len(y_columns) > 1 else None,
232
232
  )
233
233
  elif section_column is not None:
234
234
  # Preserve filtered-out gaps even when no colour grouping is selected.
@@ -238,7 +238,7 @@ def _lineplot_figure(plot_df, x_column, y_columns, *, group_columns, frequency,
238
238
  long_df = plot_df[[x_column, section_column, *y_columns]].melt(
239
239
  id_vars=[x_column, section_column], var_name="variable", value_name="value")
240
240
  fig = px.line(long_df, x=x_column, y="value", color="variable",
241
- line_group=section_column, facet_row="variable")
241
+ line_group=section_column)
242
242
  elif identifier_column is not None:
243
243
  # With no explicit grouping, retain the normal Y-variable
244
244
  # colouring while using identifier to keep each monitor's
@@ -263,7 +263,7 @@ def _lineplot_figure(plot_df, x_column, y_columns, *, group_columns, frequency,
263
263
  y="value",
264
264
  color="variable",
265
265
  line_group=identifier_column,
266
- facet_row="variable",
266
+ line_dash="variable",
267
267
  )
268
268
  elif section_column is not None:
269
269
  if len(y_columns) == 1:
@@ -286,9 +286,88 @@ def _lineplot_figure(plot_df, x_column, y_columns, *, group_columns, frequency,
286
286
  y="value",
287
287
  color="variable",
288
288
  line_group=section_column,
289
- facet_row="variable",
289
+ line_dash="variable",
290
290
  )
291
291
  else:
292
292
  fig = px.line(plot_df, x=x_column, y=y_columns)
293
293
  return fig
294
294
 
295
+
296
+
297
+ def _style_multi_y_legend(fig, spec, mode, palette, trace_colors, line_style, legend_labels):
298
+ """Give multi-Y line plots separate variable/group legend keys."""
299
+ from .appearance import _parse_label_map
300
+ from .multi_y import multi_y_styles
301
+ from plotly.colors import qualitative
302
+ import plotly.graph_objects as go
303
+
304
+ variables = [str(v) for v in spec["ys"]]
305
+ groups = [str(v) for v in spec["data"]["__plot_group"].dropna().unique()] if spec["groups"] else []
306
+ palettes = getattr(qualitative, palette, None) if palette != "Default" else None
307
+ colors, dashes, variables, groups = multi_y_styles(variables, groups, palettes or qualitative.Plotly,
308
+ mode, _parse_label_map(trace_colors), _parse_label_map(line_style))
309
+ rename = _parse_label_map(legend_labels)
310
+ for trace in fig.data:
311
+ if trace.type not in ("scatter", "scattergl"):
312
+ continue
313
+ name = str(trace.name or "")
314
+ # Plotly Express names traces "group, variable" when both encodings are present.
315
+ group = None
316
+ variable = None
317
+ if groups:
318
+ for g in groups:
319
+ for v in variables:
320
+ if name == f"{g}, {v}" or name == f"{v}, {g}":
321
+ group, variable = g, v
322
+ break
323
+ if variable is not None:
324
+ break
325
+ else:
326
+ variable = name if name in variables else next((v for v in variables if name.endswith(v)), None)
327
+ if variable is None:
328
+ continue
329
+ key = (variable, group)
330
+ trace.line.color = colors[key]
331
+ trace.line.dash = {"-":"solid", "--":"dash", ":":"dot", "-.":"dashdot"}.get(dashes[key], dashes[key])
332
+ trace.showlegend = False
333
+ trace.legendgroup = f"series:{group}:{variable}"
334
+ if mode == "none":
335
+ for group in groups:
336
+ key = (variables[0], group)
337
+ fig.add_trace(go.Scatter(
338
+ x=[None], y=[None], mode="lines", name=rename.get(group, group),
339
+ line=dict(color=colors[key], dash={"-":"solid", "--":"dash", ":":"dot", "-.":"dashdot"}.get(dashes[key], "solid")),
340
+ legendgroup=f"group:{group}", showlegend=True, hoverinfo="skip",
341
+ ))
342
+ fig.update_layout(showlegend=bool(groups))
343
+ return
344
+ if mode == "both":
345
+ for group in (groups or [None]):
346
+ for variable in variables:
347
+ key = (variable, group)
348
+ label = f"{group}, {variable}" if group is not None else variable
349
+ fig.add_trace(go.Scatter(
350
+ x=[None], y=[None], mode="lines", name=rename.get(label, label),
351
+ line=dict(color=colors[key], dash="solid"),
352
+ legendgroup=f"series:{group}:{variable}", showlegend=True, hoverinfo="skip",
353
+ ))
354
+ fig.update_layout(showlegend=True)
355
+ return
356
+ # Legend-only proxy traces avoid duplicating real observations and preserve hover data.
357
+ added_section = False
358
+ for heading, items, by_variable in (("Y variable", variables, True), ("Grouping", groups, False)):
359
+ if not items:
360
+ continue
361
+ if added_section:
362
+ # A blank legend row separating the variable and grouping keys.
363
+ fig.add_trace(go.Scatter(x=[None], y=[None], mode="lines", name=" ",
364
+ line=dict(color="rgba(0,0,0,0)", width=0),
365
+ showlegend=True, hoverinfo="skip", legendgroup="separator"))
366
+ added_section = True
367
+ for index, item in enumerate(items):
368
+ key = (item, groups[0] if groups else None) if by_variable else (variables[0], item)
369
+ fig.add_trace(go.Scatter(x=[None], y=[None], mode="lines", name=rename.get(item, item),
370
+ line=dict(color=colors[key], dash={"-":"solid", "--":"dash", ":":"dot", "-.":"dashdot"}.get(dashes[key], "solid")),
371
+ legendgroup=heading, legendgrouptitle_text=heading if index == 0 else None,
372
+ showlegend=True, hoverinfo="skip"))
373
+ fig.update_layout(showlegend=True)
@@ -83,6 +83,7 @@ def create_summary_app(
83
83
  raise ValueError(f"Unknown plot_type {plot_type!r}. Choose one of: {', '.join(PLOT_TYPES)}")
84
84
 
85
85
  subset_columns = columns
86
+ max_subset_stages = max(2, len(subset_columns) + 1)
86
87
  # Stable choices are shared across tabs. Computing unique values from the
87
88
  # full dataset on every filter change was expensive for large summaries.
88
89
  subset_choice_cache = {}
@@ -168,9 +169,35 @@ def create_summary_app(
168
169
  ui.output_ui("x_control"),
169
170
  ui.output_ui("y_control")),
170
171
  ui.nav_panel("Plot filters",
171
- ui.input_selectize("subset_columns", "Subset columns",
172
- choices={c: c for c in subset_columns}, selected=initial("subset_columns", []), multiple=True),
173
- ui.output_ui("subset_filters"),
172
+ *[
173
+ ui.div(
174
+ ui.input_select(f"subset_column_{i}", f"Subset column {i + 1}",
175
+ choices={"": "Select a column", **{c: c for c in subset_columns}},
176
+ selected=(initial("subset_chain", [{}] * max_subset_stages)[i].get("column", "")
177
+ if i < len(initial("subset_chain", [])) else "")),
178
+ ui.output_ui(f"subset_values_ui_{i}"),
179
+ ui.input_select(f"subset_style_{i}", "Filter style",
180
+ choices={k: k for k in ("all", "any", "negative all", "negative any")},
181
+ selected=(initial("subset_chain", [])[i].get("style", "all")
182
+ if i < len(initial("subset_chain", [])) else "all")),
183
+ class_="mb-3",
184
+ ) if i == 0 else ui.panel_conditional(
185
+ " && ".join(f"input.subset_column_{j} !== ''" for j in range(i)),
186
+ ui.div(
187
+ ui.input_select(f"subset_column_{i}", f"Subset column {i + 1}",
188
+ choices={"": "Select a column", **{c: c for c in subset_columns}},
189
+ selected=(initial("subset_chain", [])[i].get("column", "")
190
+ if i < len(initial("subset_chain", [])) else "")),
191
+ ui.output_ui(f"subset_values_ui_{i}"),
192
+ ui.input_select(f"subset_style_{i}", "Filter style",
193
+ choices={k: k for k in ("all", "any", "negative all", "negative any")},
194
+ selected=(initial("subset_chain", [])[i].get("style", "all")
195
+ if i < len(initial("subset_chain", [])) else "all")),
196
+ class_="mb-3",
197
+ ),
198
+ )
199
+ for i in range(max_subset_stages)
200
+ ],
174
201
  ui.input_selectize("numeric_subset_columns", "Numerical subset",
175
202
  choices={c: c for c in numeric_columns},
176
203
  selected=initial("numeric_subset_columns", []), multiple=True),
@@ -213,6 +240,7 @@ def create_summary_app(
213
240
  ui.help_text("DPI affects Matplotlib plot rendering. Export DPI is set separately when exporting."),
214
241
  ) if backend == "matplotlib" else ui.div())),
215
242
  ui.nav_panel("Legend",
243
+ ui.panel_conditional("input.plot_type === 'line' && input.y && input.y.length > 1", ui.input_select("multi_y_color_by", "Multiple Y variables: colour by", choices={"group":"Grouping (default)", "variable":"Y variable", "both":"Both", "none":"None"}, selected=initial("multi_y_color_by", "group"))),
216
244
  ui.input_select("legend_position", "Legend position", choices={k:k.title() for k in ("right", "left", "top", "bottom")}, selected=initial("legend_position", "right")),
217
245
  ui.input_checkbox("legend_inside", "Place legend inside plot", value=initial("legend_inside", False)),
218
246
  ui.input_text_area("legend_labels", "Legend labels (old=new; ...)", value=initial("legend_labels", ""), rows=6),
@@ -220,7 +248,7 @@ def create_summary_app(
220
248
  ui.input_numeric("legend_font_size", "Legend font size", value=initial("legend_font_size", 12), min=6, max=72)),
221
249
  ui.nav_panel("Axes",
222
250
  ui.input_text("x_axis_label", "X-axis label", value=initial("x_axis_label", "")),
223
- ui.input_text("y_axis_label", "Y-axis label", value=initial("y_axis_label", "")),
251
+ ui.input_text_area("y_axis_label", "Y-axis label", value=initial("y_axis_label", ""), rows=3),
224
252
  ui.layout_columns(
225
253
  (ui.input_text_area("x_min", "X minimum", value=initial("x_min", ""), rows=4) if backend == "matplotlib" else ui.input_text("x_min", "X minimum", value="")),
226
254
  (ui.input_text_area("x_max", "X maximum", value=initial("x_max", ""), rows=4) if backend == "matplotlib" else ui.input_text("x_max", "X maximum", value="")),
@@ -289,19 +317,29 @@ def create_summary_app(
289
317
  @module.server
290
318
  def plot_tab_server(input, output, session, initial_filters=None):
291
319
  initial_filters = initial_filters or {}
292
- @render.ui
293
- def subset_filters():
294
- controls = []
295
- for i, name in enumerate(list(input.subset_columns())):
320
+ # One stable output per stage: changing a later stage does not
321
+ # recreate the inputs belonging to earlier stages.
322
+ def _make_subset_values_ui(stage):
323
+ def _values_ui():
324
+ try:
325
+ name = input[f"subset_column_{stage}"]()
326
+ except Exception:
327
+ name = ""
296
328
  if name not in column_map:
297
- continue
329
+ return ui.div()
298
330
  choices, _ = cached_subset_choices(name)
299
- controls.append(ui.input_selectize(
300
- f"subset_values_{i}", f"{name} values", choices=choices,
301
- selected=initial_filters.get("subset_values", {}).get(name, []),
302
- multiple=True,
303
- ))
304
- return ui.div(*controls) if controls else ui.help_text("Select columns to filter this plot.")
331
+ saved = initial_filters.get("subset_chain", [])
332
+ selected = (saved[stage].get("values", [])
333
+ if stage < len(saved) and saved[stage].get("column") == name else [])
334
+ return ui.input_selectize(f"subset_values_{stage}", f"{name} values",
335
+ choices=choices, selected=selected, multiple=True)
336
+ # Shiny registers outputs by the decorated function name.
337
+ # Give each generated output its own stable name before rendering.
338
+ _values_ui.__name__ = f"subset_values_ui_{stage}"
339
+ return render.ui(_values_ui)
340
+
341
+ for _stage in range(max_subset_stages):
342
+ output(_make_subset_values_ui(_stage))
305
343
 
306
344
  @render.ui
307
345
  def numeric_subset_bounds():
@@ -324,24 +362,33 @@ def create_summary_app(
324
362
 
325
363
  @reactive.calc
326
364
  def filter_state():
327
- names = list(input.subset_columns())
328
- values = {}
329
- for i, name in enumerate(names):
330
- if name in column_map:
331
- values[name] = list(input[f"subset_values_{i}"]())
332
- return {"columns": names, "values": values,
365
+ stages = []
366
+ for i in range(max_subset_stages):
367
+ try:
368
+ name = input[f"subset_column_{i}"]()
369
+ except Exception:
370
+ break
371
+ if name not in column_map:
372
+ break
373
+ try:
374
+ keys = list(input[f"subset_values_{i}"]() or [])
375
+ except Exception:
376
+ keys = []
377
+ try:
378
+ style = input[f"subset_style_{i}"]() or "all"
379
+ except Exception:
380
+ style = "all"
381
+ stages.append({"column": name, "values": keys, "style": style})
382
+ return {"stages": stages,
333
383
  "start": input.datetime_start().strip(),
334
384
  "end": input.datetime_end().strip()}
335
385
 
336
386
  @reactive.calc
337
387
  def filtered_summary():
338
388
  state = filter_state()
339
- filters = {}
340
- for name, keys in state["values"].items():
341
- _, mapping = cached_subset_choices(name)
342
- selected = [mapping[key] for key in keys if key in mapping]
343
- if selected:
344
- filters[column_map[name]] = selected
389
+ # Apply each stage sequentially through Summary.subset().
390
+ # Empty value selections are intentionally no-ops.
391
+ stages = state["stages"]
345
392
  # Preserve original row adjacency before numerical filtering so a
346
393
  # line cannot bridge observations removed by a numeric bound.
347
394
  source = summary
@@ -352,7 +399,16 @@ def create_summary_app(
352
399
  source.loc[order.index, "__source_row_order"] = order.groupby("identifier", dropna=False).cumcount().to_numpy()
353
400
  else:
354
401
  source.loc[order.index, "__source_row_order"] = np.arange(len(order))
355
- current = _apply_filters(source, filters, state["start"], state["end"])
402
+ current = source
403
+ for stage in stages:
404
+ _, mapping = cached_subset_choices(stage["column"])
405
+ selected = [mapping[key] for key in stage["values"] if key in mapping]
406
+ if selected:
407
+ current = current.subset(
408
+ {column_map[stage["column"]]: selected},
409
+ filter_style=stage["style"],
410
+ )
411
+ current = _apply_filters(current, {}, state["start"], state["end"])
356
412
  for name in list(input.numeric_subset_columns() or []):
357
413
  if name not in numeric_columns:
358
414
  continue
@@ -520,6 +576,10 @@ def create_summary_app(
520
576
  @reactive.calc
521
577
  def selected_data():
522
578
  current = filtered_summary()
579
+ # Chained subsets may legitimately produce zero rows. Do not
580
+ # call group_stat() on an empty Summary (it returns None).
581
+ if current is None or current.empty:
582
+ return pd.DataFrame()
523
583
  kind = input.plot_type()
524
584
  x_input = input.x()
525
585
  selected_x = ([x_input] if isinstance(x_input, str) else list(x_input or []))
@@ -533,6 +593,15 @@ def create_summary_app(
533
593
  value_names = ([y_input] if isinstance(y_input, str) else list(y_input or []))
534
594
  if not value_names or any(c not in column_map for c in value_names):
535
595
  return pd.DataFrame()
596
+ # Changing plot type updates the X selector asynchronously.
597
+ # Until that update arrives, a histogram may still hold the
598
+ # previous line plot's string/datetime X column. Never pass
599
+ # such a column to group_stat(mean/median/etc.).
600
+ if kind == "histogram" and any(
601
+ not pd.api.types.is_numeric_dtype(current[column_map[name]])
602
+ for name in value_names
603
+ ):
604
+ return pd.DataFrame()
536
605
 
537
606
  selected_groups = list(input.group_by() or [])
538
607
  # Derive hour/day/etc. through group_stat without aggregation.
@@ -581,15 +650,30 @@ def create_summary_app(
581
650
  # an unrelated frequency column instead.
582
651
  return pd.DataFrame()
583
652
  if should_aggregate:
653
+ # An aggregation without a frequency or grouping has no
654
+ # plotting groups. Keep the reactive chain alive; the
655
+ # prepared_plot() validation displays a useful message.
656
+ if frequency is None and not grouping_by:
657
+ return pd.DataFrame()
658
+ if current is None or current.empty:
659
+ return pd.DataFrame()
584
660
  current = _aggregate(
585
661
  current, value_columns, frequency, grouping_by, aggregate,
586
662
  only_complete=(bool(input.only_complete()) if frequency is not None else True),
587
663
  flooring_timestamp=(bool(input.flooring_timestamp()) if frequency is not None else True),
588
664
  )
665
+ if current is None or current.empty:
666
+ return pd.DataFrame()
589
667
  return current
590
668
 
591
669
  @reactive.calc
592
670
  def prepared_plot():
671
+ # Validate before evaluating selected_data(), since invalid
672
+ # aggregation options must not poison reactive calculations.
673
+ if (AGGREGATES.get(input.aggregate()) is not None
674
+ and FREQUENCIES.get(input.frequency()) is None
675
+ and not list(input.group_by() or [])):
676
+ return {"error": "Select a frequency or at least one Group by variable to apply a statistical function"}
593
677
  plot_df = selected_data()
594
678
  selected_plot = input.plot_type()
595
679
  x_input = input.x()
@@ -752,7 +836,7 @@ def create_summary_app(
752
836
  defaults["colors"] = trace_defaults["colors"]
753
837
  ui.update_text("title", value="Summary plot")
754
838
  ui.update_text("x_axis_label", value=defaults["x_label"])
755
- ui.update_text("y_axis_label", value=defaults["y_label"])
839
+ ui.update_text_area("y_axis_label", value=defaults["y_label"])
756
840
  limits = _axis_bounds_from_spec(
757
841
  spec,
758
842
  bins=int(input.bins()) if spec["kind"] == "histogram" else 30,
@@ -876,7 +960,9 @@ def create_summary_app(
876
960
  @reactive.event(input.plot_type, ignore_init=True)
877
961
  def reset_on_plot_type_change():
878
962
  # Reset every plot-specific selection when the plot type changes.
879
- ui.update_selectize("subset_columns", selected=[])
963
+ for _i in range(max_subset_stages):
964
+ ui.update_select(f"subset_column_{_i}", selected="")
965
+ ui.update_select(f"subset_style_{_i}", selected="all")
880
966
  ui.update_selectize("numeric_subset_columns", selected=[])
881
967
  ui.update_text("datetime_start", value="")
882
968
  ui.update_text("datetime_end", value="")
@@ -886,8 +972,8 @@ def create_summary_app(
886
972
  ui.update_select("frequency", selected="None")
887
973
  ui.update_select("aggregate", selected="None")
888
974
  ui.update_text("title", value="")
889
- for field in ("x_axis_label", "y_axis_label"):
890
- ui.update_text(field, value="")
975
+ ui.update_text("x_axis_label", value="")
976
+ ui.update_text_area("y_axis_label", value="")
891
977
  for field in ("x_min", "x_max", "y_min", "y_max"):
892
978
  if backend == "matplotlib":
893
979
  ui.update_text_area(field, value="")
@@ -989,6 +1075,11 @@ def create_summary_app(
989
1075
  scatter_size=input.scatter_size(), scatter_marker=input.scatter_marker(),
990
1076
  scatter_alpha=input.scatter_alpha(), plot_kind=input.plot_type(),
991
1077
  )
1078
+ if input.plot_type() == "line" and len(prepared_plot().get("ys", [])) > 1:
1079
+ from .plotly_renderer import _style_multi_y_legend
1080
+ _style_multi_y_legend(styled, prepared_plot(), input.multi_y_color_by(),
1081
+ input.palette(), input.trace_colors(), input.line_style(),
1082
+ input.legend_labels())
992
1083
  if input.plot_type() == "box" and not input.box_showfliers():
993
1084
  from .data import _box_whisker_limits
994
1085
  spec = prepared_plot()
@@ -1020,6 +1111,7 @@ def create_summary_app(
1020
1111
  y_min=_optional_input("y_min", ""), y_max=_optional_input("y_max", ""), template=_optional_input("template", "plotly"),
1021
1112
  legend_position=_optional_input("legend_position", "right"), legend_inside=_optional_input("legend_inside", False),
1022
1113
  legend_columns=max(1, int(_optional_input("legend_columns", 1) or 1)),
1114
+ multi_y_color_by=_optional_input("multi_y_color_by", "group"),
1023
1115
  show_grid=_optional_input("show_grid", True),
1024
1116
  x_major_ticks=_optional_input("x_major_ticks", 6), y_major_ticks=_optional_input("y_major_ticks", 6),
1025
1117
  x_minor_ticks=_optional_input("x_minor_ticks", False), y_minor_ticks=_optional_input("y_minor_ticks", False),
@@ -1064,10 +1156,10 @@ def create_summary_app(
1064
1156
  return _matplotlib_direct_figure(prepared_plot(), **matplotlib_options())
1065
1157
 
1066
1158
  def snapshot():
1067
- keys = ("plot_type", "x", "y", "subset_columns", "numeric_subset_columns", "datetime_start", "datetime_end",
1159
+ keys = ("plot_type", "x", "y", "numeric_subset_columns", "datetime_start", "datetime_end",
1068
1160
  "group_by", "frequency", "aggregate", "only_complete", "flooring_timestamp",
1069
1161
  "palette", "template", "title", "title_font_size", "plot_dpi", "legend_position",
1070
- "legend_inside", "legend_labels", "legend_columns", "legend_font_size",
1162
+ "legend_inside", "legend_labels", "legend_columns", "legend_font_size", "multi_y_color_by",
1071
1163
  "x_axis_label", "y_axis_label", "x_min", "x_max", "y_min", "y_max",
1072
1164
  "show_grid", "x_major_ticks", "x_minor_ticks", "y_major_ticks",
1073
1165
  "y_minor_ticks", "axis_font_size", "tick_font_size", "trace_colors",
@@ -1085,13 +1177,24 @@ def create_summary_app(
1085
1177
  except Exception:
1086
1178
  # Inputs in inactive tabs may not have been initialized yet.
1087
1179
  pass
1088
- subset = {}
1089
- for i, name in enumerate(result.get("subset_columns", []) or []):
1180
+ chain = []
1181
+ for i in range(max_subset_stages):
1182
+ try:
1183
+ name = input[f"subset_column_{i}"]()
1184
+ except Exception:
1185
+ break
1186
+ if name not in column_map:
1187
+ break
1090
1188
  try:
1091
- subset[name] = list(input[f"subset_values_{i}"]() or [])
1189
+ values = list(input[f"subset_values_{i}"]() or [])
1092
1190
  except Exception:
1093
- subset[name] = initial_filters.get("subset_values", {}).get(name, [])
1094
- result["subset_values"] = subset
1191
+ values = []
1192
+ try:
1193
+ style = input[f"subset_style_{i}"]() or "all"
1194
+ except Exception:
1195
+ style = "all"
1196
+ chain.append({"column": name, "values": values, "style": style})
1197
+ result["subset_chain"] = chain
1095
1198
  numeric_bounds = {}
1096
1199
  for name in result.get("numeric_subset_columns", []) or []:
1097
1200
  if name not in numeric_columns:
@@ -1106,12 +1209,292 @@ def create_summary_app(
1106
1209
  result["numeric_subset_bounds"] = numeric_bounds
1107
1210
  return result
1108
1211
 
1212
+ # Expose the same dataframe that feeds plot preparation/export.
1213
+ # The app-level New table action captures it as a static snapshot.
1214
+ snapshot.plot_dataframe = selected_data
1109
1215
  return snapshot
1110
1216
 
1111
1217
 
1218
+ @module.ui
1219
+ def table_tab_ui(frame):
1220
+ available = _alphabetical(frame.columns)
1221
+ numeric = _alphabetical(_numeric_columns(frame))
1222
+ groups = _alphabetical([c for c in available if c not in numeric])
1223
+ stages = max(2, len(available) + 1)
1224
+ return ui.div(
1225
+ ui.layout_sidebar(
1226
+ ui.sidebar(
1227
+ ui.div(ui.navset_tab(
1228
+ ui.nav_panel("Table filters",
1229
+ *[
1230
+ ui.div(
1231
+ ui.input_select(f"subset_column_{i}", f"Subset column {i + 1}",
1232
+ choices={"": "Select a column", **{c: c for c in available}}),
1233
+ ui.output_ui(f"subset_values_ui_{i}"),
1234
+ ui.input_select(f"subset_style_{i}", "Filter style",
1235
+ choices={k: k for k in ("all", "any", "negative all", "negative any")}),
1236
+ class_="mb-3",
1237
+ ) if i == 0 else ui.panel_conditional(
1238
+ " && ".join(f"input.subset_column_{j} !== ''" for j in range(i)),
1239
+ ui.div(
1240
+ ui.input_select(f"subset_column_{i}", f"Subset column {i + 1}",
1241
+ choices={"": "Select a column", **{c: c for c in available}}),
1242
+ ui.output_ui(f"subset_values_ui_{i}"),
1243
+ ui.input_select(f"subset_style_{i}", "Filter style",
1244
+ choices={k: k for k in ("all", "any", "negative all", "negative any")}),
1245
+ class_="mb-3",
1246
+ ),
1247
+ ) for i in range(stages)
1248
+ ],
1249
+ ui.input_selectize("numeric_subset_columns", "Numerical subset",
1250
+ choices={c: c for c in numeric}, multiple=True),
1251
+ ui.output_ui("numeric_subset_bounds"),
1252
+ ui.input_text("datetime_start", "Start datetime",
1253
+ placeholder="YYYY-MM-DD, HH:MM:SS, or YYYY-MM-DD HH:MM:SS"),
1254
+ ui.input_text("datetime_end", "End datetime",
1255
+ placeholder="YYYY-MM-DD, HH:MM:SS, or YYYY-MM-DD HH:MM:SS"),
1256
+ ),
1257
+ ui.nav_panel("Frequency/Grouping/Statistical aggregation",
1258
+ ui.input_selectize("group_by", "Group by", choices={c: c for c in groups},
1259
+ multiple=True),
1260
+ ui.input_select("frequency", "Frequency / aggregation mode",
1261
+ choices={k: k for k in FREQUENCIES}),
1262
+ ui.input_select("aggregate", "Apply statistical function",
1263
+ choices={k: k for k in AGGREGATES}),
1264
+ ui.output_ui("frequency_options"),
1265
+ ),
1266
+ id="table_sidebar_tabs"), class_="summary-sidebar-tabs"),
1267
+ title="Table controls", open="closed", width="320px",
1268
+ ),
1269
+ ui.card(
1270
+ ui.card_header("Data table"),
1271
+ ui.div(ui.output_data_frame("table_grid"), class_="summary-table-grid-area"),
1272
+ class_="summary-table-grid-card",
1273
+ ),
1274
+ ),
1275
+ class_="summary-table-tab",
1276
+ )
1277
+
1278
+ @module.server
1279
+ def table_tab_server(input, output, session, frame):
1280
+ # Preserve Summary methods on snapshots made from plots and tables.
1281
+ if hasattr(summary, "subset") and not hasattr(frame, "subset"):
1282
+ frame = summary._constructor(frame)
1283
+ # Each table starts from its own snapshot and processes it independently.
1284
+ available = _alphabetical(frame.columns)
1285
+ numeric = _alphabetical(_numeric_columns(frame))
1286
+ stages = max(2, len(available) + 1)
1287
+ cache = {}
1288
+
1289
+ def choices_for(name):
1290
+ if name not in cache:
1291
+ cache[name] = _subset_choices(frame, name)
1292
+ return cache[name]
1293
+
1294
+ def make_values_ui(stage):
1295
+ def values_ui():
1296
+ try:
1297
+ name = input[f"subset_column_{stage}"]()
1298
+ except Exception:
1299
+ return ui.div()
1300
+ if name not in frame.columns:
1301
+ return ui.div()
1302
+ choices, _ = choices_for(name)
1303
+ return ui.input_selectize(f"subset_values_{stage}", f"{name} values",
1304
+ choices=choices, multiple=True)
1305
+ values_ui.__name__ = f"subset_values_ui_{stage}"
1306
+ return render.ui(values_ui)
1307
+
1308
+ for stage in range(stages):
1309
+ output(make_values_ui(stage))
1310
+
1311
+ @render.ui
1312
+ def numeric_subset_bounds():
1313
+ controls = []
1314
+ for name in list(input.numeric_subset_columns() or []):
1315
+ if name not in numeric:
1316
+ continue
1317
+ index = numeric.index(name)
1318
+ controls.append(ui.div(
1319
+ ui.tags.strong(name),
1320
+ ui.input_text(f"numeric_gt_{index}", "Greater than", placeholder="No lower bound"),
1321
+ ui.input_text(f"numeric_lt_{index}", "Less than", placeholder="No upper bound"),
1322
+ ))
1323
+ return ui.div(*controls)
1324
+
1325
+ @render.ui
1326
+ def frequency_options():
1327
+ if FREQUENCIES.get(input.frequency()) is None:
1328
+ return ui.div()
1329
+ return ui.div(
1330
+ ui.input_checkbox("only_complete", "Only complete intervals", value=True),
1331
+ ui.input_checkbox("flooring_timestamp", "Floor timestamps", value=True),
1332
+ )
1333
+
1334
+ @reactive.calc
1335
+ def table_data():
1336
+ current = frame.copy(deep=True)
1337
+ for stage in range(stages):
1338
+ try:
1339
+ name = input[f"subset_column_{stage}"]()
1340
+ except Exception:
1341
+ break
1342
+ if name not in frame.columns:
1343
+ break
1344
+ try:
1345
+ keys = list(input[f"subset_values_{stage}"]() or [])
1346
+ except Exception:
1347
+ keys = []
1348
+ _, mapping = choices_for(name)
1349
+ values = [mapping[key] for key in keys if key in mapping]
1350
+ if values:
1351
+ try:
1352
+ style = input[f"subset_style_{stage}"]() or "all"
1353
+ except Exception:
1354
+ style = "all"
1355
+ current = current.subset({name: values}, filter_style=style)
1356
+ current = _apply_filters(current, {}, input.datetime_start(), input.datetime_end())
1357
+ for name in list(input.numeric_subset_columns() or []):
1358
+ if name not in numeric:
1359
+ continue
1360
+ index = numeric.index(name)
1361
+ values = pd.to_numeric(current[name], errors="coerce")
1362
+ for suffix in ("gt", "lt"):
1363
+ try:
1364
+ bound = float(str(input[f"numeric_{suffix}_{index}"]()).strip())
1365
+ except (TypeError, ValueError, KeyError):
1366
+ continue
1367
+ if not np.isfinite(bound):
1368
+ continue
1369
+ mask = values.gt(bound) if suffix == "gt" else values.lt(bound)
1370
+ current = current.loc[mask.fillna(False)].copy()
1371
+ values = values.loc[current.index]
1372
+ if current.empty:
1373
+ return current
1374
+ aggregate = AGGREGATES.get(input.aggregate())
1375
+ if aggregate is not None:
1376
+ frequency = FREQUENCIES.get(input.frequency())
1377
+ grouping = list(input.group_by() or [])
1378
+ if frequency is None and not grouping:
1379
+ return pd.DataFrame()
1380
+ # Do not exclude identifier by name: preserve the existing
1381
+ # Summary.group_stat() semantics and let the grouping selection
1382
+ # determine whether a numeric column is an aggregation value.
1383
+ # Entirely missing numeric columns remain excluded.
1384
+ value_cols = [name for name in numeric if name not in grouping
1385
+ and current[name].notna().any()]
1386
+ if not value_cols:
1387
+ return pd.DataFrame()
1388
+ if not hasattr(current, "group_stat"):
1389
+ return pd.DataFrame()
1390
+ complete = bool(input.only_complete()) if frequency is not None else False
1391
+ floor = bool(input.flooring_timestamp()) if frequency is not None else True
1392
+ if complete and frequency is not None:
1393
+ # group_stat requires *every* value column to be complete in
1394
+ # the same interval. Evaluate each measurement independently,
1395
+ # then align the resulting aggregate columns on group keys.
1396
+ # This avoids one sparse sensor hiding all other measurements.
1397
+ parts = []
1398
+ for value_col in value_cols:
1399
+ part = _aggregate(current, [value_col], frequency, grouping,
1400
+ aggregate, only_complete=True, flooring_timestamp=floor)
1401
+ if part is not None and not part.empty:
1402
+ parts.append(pd.DataFrame(part).reset_index(drop=True))
1403
+ if parts:
1404
+ current = parts[0]
1405
+ for part in parts[1:]:
1406
+ # Merge on actual grouping/time columns produced by
1407
+ # group_stat(), not on the frequency option string.
1408
+ measurement_cols = {f"{name}_{aggregate}" for name in value_cols}
1409
+ keys = [col for col in current.columns
1410
+ if col in part.columns and col not in measurement_cols]
1411
+ if keys:
1412
+ current = current.merge(part, on=keys, how="outer", sort=False)
1413
+ else:
1414
+ # No reliable shared keys: preserve the rows of
1415
+ # each result rather than triggering a merge error.
1416
+ current = pd.concat([current, part], ignore_index=True)
1417
+ else:
1418
+ current = pd.DataFrame()
1419
+ else:
1420
+ current = _aggregate(current, value_cols, frequency, grouping, aggregate,
1421
+ only_complete=False, flooring_timestamp=floor)
1422
+ return pd.DataFrame() if current is None else current
1423
+
1424
+ @render.data_frame
1425
+ def table_grid():
1426
+ result = table_data()
1427
+ # Convert Summary/Group output to plain, grid-compatible pandas
1428
+ # columns. Completeness filtering may legitimately produce zero rows.
1429
+ result = pd.DataFrame(result).reset_index(drop=True)
1430
+ # Hide columns without any observed values in the current table.
1431
+ # This is presentation-only: the underlying table data is retained
1432
+ # for subsequent filtering, grouping and new-table snapshots.
1433
+ result = result.dropna(axis=1, how="all")
1434
+ result.columns = [str(c) for c in result.columns]
1435
+ result = result.loc[:, ~result.columns.duplicated()].copy()
1436
+ for col in result.columns:
1437
+ series = result[col]
1438
+ if pd.api.types.is_timedelta64_dtype(series.dtype):
1439
+ result[col] = series.astype(str)
1440
+ elif pd.api.types.is_object_dtype(series.dtype):
1441
+ # Shiny's browser grid cannot reliably infer mixed Python
1442
+ # objects (including nested values and pd.NA).
1443
+ nonnull = series.dropna()
1444
+ if len(nonnull) and not nonnull.map(lambda v: isinstance(v, (str, int, float, bool, datetime, date, pd.Timestamp))).all():
1445
+ result[col] = series.map(lambda v: str(v) if not pd.isna(v) else None)
1446
+ elif pd.api.types.is_numeric_dtype(series.dtype):
1447
+ result[col] = series.replace([np.inf, -np.inf], np.nan)
1448
+ return render.DataGrid(result, filters=True, width="100%", height="100%")
1449
+
1450
+
1112
1451
  app_ui = ui.page_fluid(
1113
1452
  ui.tags.style("""
1114
1453
  .container, .container-fluid {max-width: none !important;}
1454
+ /* Give table grid headers and their filter inputs 50% more space. */
1455
+ .summary-table-tab .shiny-data-grid table th,
1456
+ .summary-table-tab .shiny-data-grid table td,
1457
+ .summary-table-tab .shiny-data-grid .cell,
1458
+ .summary-table-tab .shiny-data-grid .header {
1459
+ min-width: 150px !important;
1460
+ }
1461
+ .summary-table-tab .shiny-data-grid input {min-width: 120px;}
1462
+ /* Explicit viewport sizing avoids zero-height nested flex children. */
1463
+ .summary-table-tab {
1464
+ height: calc(100dvh - 185px);
1465
+ min-height: 360px;
1466
+ overflow: hidden;
1467
+ }
1468
+ .summary-table-tab > .bslib-sidebar-layout {
1469
+ height: 100%;
1470
+ min-height: 0;
1471
+ }
1472
+ .summary-table-tab .bslib-sidebar-layout > aside,
1473
+ .summary-table-tab .bslib-sidebar-layout > .sidebar {
1474
+ height: 100%;
1475
+ overflow-y: auto;
1476
+ }
1477
+ .summary-table-tab .summary-table-grid-card {
1478
+ height: 100%;
1479
+ min-height: 0;
1480
+ }
1481
+ .summary-table-tab .summary-table-grid-card .card-body {
1482
+ height: 100%;
1483
+ min-height: 0;
1484
+ display: flex;
1485
+ flex-direction: column;
1486
+ }
1487
+ .summary-table-tab .summary-table-grid-area {
1488
+ flex: 1 1 auto;
1489
+ min-height: 0;
1490
+ height: 100%;
1491
+ overflow: hidden;
1492
+ }
1493
+ .summary-table-tab .summary-table-grid-area > * {
1494
+ height: 100% !important;
1495
+ min-height: 0;
1496
+ }
1497
+
1115
1498
  .summary-plot-container {width:100%; height:auto; aspect-ratio: 2 / 1; min-width:0; position:relative;}
1116
1499
  .summary-plot-container > * {width:100% !important;}
1117
1500
  .summary-plot-container .shiny-plot-output {min-height:400px;}
@@ -1169,7 +1552,8 @@ def create_summary_app(
1169
1552
  ui.div(
1170
1553
  ui.div(
1171
1554
  ui.input_action_button("add_tab", "+ New plot", class_="btn-primary"),
1172
- ui.input_action_button("close_tab", "Close selected plot", class_="btn-outline-danger"),
1555
+ ui.input_action_button("add_table", "+ New table", class_="btn-primary"),
1556
+ ui.input_action_button("close_tab", "Close selected tab", class_="btn-outline-danger"),
1173
1557
  class_="summary-toolbar-actions",
1174
1558
  ),
1175
1559
  # Exactly one app-wide theme picker, outside dynamically added
@@ -1200,6 +1584,8 @@ def create_summary_app(
1200
1584
  next_tab = reactive.value(0)
1201
1585
  active_tabs = reactive.value(())
1202
1586
  tab_filters = {}
1587
+ table_frames = {}
1588
+ next_table = reactive.value(0)
1203
1589
 
1204
1590
  @reactive.effect
1205
1591
  @reactive.event(input.add_tab)
@@ -1223,6 +1609,36 @@ def create_summary_app(
1223
1609
  active_tabs.set((*active_tabs.get(), tab_id))
1224
1610
  ui.insert_nav_panel("plot_tabs", new_panel, select=True)
1225
1611
 
1612
+ @reactive.effect
1613
+ @reactive.event(input.add_table)
1614
+ def add_table():
1615
+ number = next_table.get() + 1
1616
+ next_table.set(number)
1617
+ tab_id = f"table_{number}"
1618
+ selected = input.plot_tabs()
1619
+ with reactive.isolate():
1620
+ if selected in table_frames:
1621
+ frame = table_frames[selected].copy(deep=True)
1622
+ elif selected in tab_filters:
1623
+ try:
1624
+ frame = tab_filters[selected].plot_dataframe()
1625
+ frame = pd.DataFrame(frame).copy(deep=True)
1626
+ except Exception:
1627
+ # A plot may have incomplete settings; show an empty
1628
+ # grid rather than disabling the New table action.
1629
+ frame = pd.DataFrame()
1630
+ else:
1631
+ frame = pd.DataFrame(summary).copy(deep=True)
1632
+ # Datagrid requires plain column names; preserve the values and
1633
+ # index as displayed columns where useful.
1634
+ frame = frame.reset_index(drop=True)
1635
+ frame.columns = [str(c) for c in frame.columns]
1636
+ panel = ui.nav_panel(f"Table {number}", table_tab_ui(tab_id, frame), value=tab_id)
1637
+ table_frames[tab_id] = frame
1638
+ table_tab_server(tab_id, frame)
1639
+ active_tabs.set((*active_tabs.get(), tab_id))
1640
+ ui.insert_nav_panel("plot_tabs", panel, select=True)
1641
+
1226
1642
  @reactive.effect
1227
1643
  @reactive.event(input.close_tab)
1228
1644
  def close_plot():
@@ -1231,6 +1647,8 @@ def create_summary_app(
1231
1647
  if selected not in tabs:
1232
1648
  return
1233
1649
  ui.remove_nav_panel("plot_tabs", target=selected)
1650
+ table_frames.pop(selected, None)
1651
+ tab_filters.pop(selected, None)
1234
1652
  active_tabs.set(tuple(t for t in tabs if t != selected))
1235
1653
  # Shiny does not provide a public API to unregister an output module.
1236
1654
  # The removed tab cannot be selected again and tab IDs are never reused.
File without changes
File without changes
File without changes