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.
- {apmtools-0.2.45 → apmtools-0.2.46}/PKG-INFO +1 -1
- {apmtools-0.2.45 → apmtools-0.2.46}/pyproject.toml +1 -1
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/appearance.py +2 -4
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/data.py +10 -6
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/matplotlib_renderer.py +45 -6
- apmtools-0.2.46/src/apmtools/shiny/multi_y.py +56 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/plotly_renderer.py +83 -4
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/ui.py +458 -40
- {apmtools-0.2.45 → apmtools-0.2.46}/.gitignore +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/LICENSE +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/README.md +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/classes.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.45 → apmtools-0.2.46}/src/apmtools/shiny/summary.py +0 -0
- {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.
|
|
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>
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
329
|
+
return ui.div()
|
|
298
330
|
choices, _ = cached_subset_choices(name)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
890
|
-
|
|
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", "
|
|
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
|
-
|
|
1089
|
-
for i
|
|
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
|
-
|
|
1189
|
+
values = list(input[f"subset_values_{i}"]() or [])
|
|
1092
1190
|
except Exception:
|
|
1093
|
-
|
|
1094
|
-
|
|
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("
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|