apmtools 0.2.52__tar.gz → 0.2.54__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.52 → apmtools-0.2.54}/PKG-INFO +1 -1
- {apmtools-0.2.52 → apmtools-0.2.54}/pyproject.toml +2 -2
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/plot_configs.py +9 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/matplotlib_renderer.py +56 -14
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/ui.py +204 -24
- {apmtools-0.2.52 → apmtools-0.2.54}/.gitignore +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/LICENSE +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/README.md +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/classes.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/plot_app.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/appearance.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/color_editor.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/data.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/filter_config.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/multi_axis.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/multi_y.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/plotly_renderer.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/summary.py +0 -0
- {apmtools-0.2.52 → apmtools-0.2.54}/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.54
|
|
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.
|
|
7
|
+
version = "0.2.54"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
|
|
10
10
|
]
|
|
@@ -26,4 +26,4 @@ shiny = [
|
|
|
26
26
|
"shinywidgets>=0.6",
|
|
27
27
|
"shinyswatch>=0.12",
|
|
28
28
|
"plotly>=6.0",
|
|
29
|
-
]
|
|
29
|
+
]
|
|
@@ -130,6 +130,15 @@ class PlotConfigManager:
|
|
|
130
130
|
self.revision += 1
|
|
131
131
|
return preset_id
|
|
132
132
|
|
|
133
|
+
def delete_saved(self, preset_id):
|
|
134
|
+
"""Delete one explicitly saved plot by stable ID, not its open draft."""
|
|
135
|
+
items = copy.deepcopy(self.summary.plots or [])
|
|
136
|
+
remaining = [p for p in items if not (p.get('id') == preset_id and p.get('type') == 'plot')]
|
|
137
|
+
if len(remaining) == len(items):
|
|
138
|
+
raise KeyError(f'Unknown saved plot: {preset_id}')
|
|
139
|
+
self.summary.plots = remaining
|
|
140
|
+
self.revision += 1
|
|
141
|
+
|
|
133
142
|
def saved(self):
|
|
134
143
|
return copy.deepcopy(self.summary.plots or [])
|
|
135
144
|
|
|
@@ -47,6 +47,17 @@ def _axis_bounds_from_spec(spec, *, bins=30, density=False, stacked=False, cumul
|
|
|
47
47
|
if "error" in spec:
|
|
48
48
|
return {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
|
|
49
49
|
df, kind, x, ys = spec["data"], spec["kind"], spec["x"], spec["ys"]
|
|
50
|
+
# A long-form Y grouping must render its value column, not a source
|
|
51
|
+
# column removed by melt(). Normalize the local spec before faceting.
|
|
52
|
+
if kind == "scatter" and "__axis_y_value" in df.columns and any(y not in df.columns for y in ys):
|
|
53
|
+
ys = ["__axis_y_value"]
|
|
54
|
+
spec = {**spec, "ys": ys}
|
|
55
|
+
|
|
56
|
+
# Reactive selection changes can briefly expose a specification whose
|
|
57
|
+
# original Y names are no longer present after a melt/reshape.
|
|
58
|
+
ys = [y for y in ys if y in df.columns]
|
|
59
|
+
if x not in df.columns or (kind != "histogram" and not ys):
|
|
60
|
+
return {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
|
|
50
61
|
if df.empty:
|
|
51
62
|
return {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
|
|
52
63
|
bounds = {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
|
|
@@ -364,6 +375,12 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
364
375
|
return fig
|
|
365
376
|
|
|
366
377
|
df, kind, x, ys = spec["data"], spec["kind"], spec["x"], spec["ys"]
|
|
378
|
+
# A long-form Y grouping must render its value column, not a source
|
|
379
|
+
# column removed by melt(). Normalize the local spec before faceting.
|
|
380
|
+
if kind == "scatter" and "__axis_y_value" in df.columns and any(y not in df.columns for y in ys):
|
|
381
|
+
ys = ["__axis_y_value"]
|
|
382
|
+
spec = {**spec, "ys": ys}
|
|
383
|
+
|
|
367
384
|
# Facet only the native Matplotlib renderer. Each panel receives the
|
|
368
385
|
# same filtered/aggregated data subset and the same appearance settings.
|
|
369
386
|
if panel_by and _axes is None:
|
|
@@ -404,19 +421,23 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
404
421
|
stacked=stacked, histtype=histtype, cumulative=cumulative, _axes=panel_ax,
|
|
405
422
|
x_major_ticks=x_major_ticks, y_major_ticks=y_major_ticks,
|
|
406
423
|
x_minor_ticks=x_minor_ticks, y_minor_ticks=y_minor_ticks)
|
|
407
|
-
|
|
408
|
-
|
|
424
|
+
# Resolve the axes once; reuse the same ordered panel collection for
|
|
425
|
+
# legend layout rather than repeatedly materializing axes.flat.
|
|
426
|
+
flat_axes = axes.flat
|
|
427
|
+
active_axes = [flat_axes[i] for i in range(len(partitions))]
|
|
428
|
+
for i in range(len(partitions), axes.size):
|
|
429
|
+
flat_axes[i].set_visible(False)
|
|
409
430
|
if legend_inside:
|
|
410
|
-
for panel_ax in
|
|
431
|
+
for panel_ax in active_axes:
|
|
411
432
|
if panel_ax.get_legend() is not None:
|
|
412
433
|
panel_ax.get_legend().set_in_layout(False)
|
|
413
434
|
fig.tight_layout()
|
|
414
435
|
# V77 used tight_layout for facets; reserve generous gutters
|
|
415
436
|
# without changing V83's legend behaviour or column control.
|
|
416
437
|
fig.subplots_adjust(wspace=panel_wspace, hspace=panel_hspace)
|
|
417
|
-
_fit_inside_legends(fig,
|
|
438
|
+
_fit_inside_legends(fig, active_axes, legend_font_size, legend_columns)
|
|
418
439
|
else:
|
|
419
|
-
_place_outside_legend(fig,
|
|
440
|
+
_place_outside_legend(fig, active_axes,
|
|
420
441
|
legend_position, legend_font_size, legend_columns,
|
|
421
442
|
panel_hspace=panel_hspace, panel_wspace=panel_wspace)
|
|
422
443
|
return fig
|
|
@@ -457,27 +478,45 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
457
478
|
variable_names = list(ys)
|
|
458
479
|
x_names = list(spec.get("x_variables", [x]))
|
|
459
480
|
group_names = style_groups if group_col else []
|
|
481
|
+
# Reuse stable positions for per-variable markers and hatches.
|
|
482
|
+
x_position = {name: i for i, name in enumerate(x_names)}
|
|
483
|
+
y_position = {name: i for i, name in enumerate(ys)}
|
|
484
|
+
group_position = {name: i for i, name in enumerate(group_names)}
|
|
460
485
|
active = set()
|
|
461
486
|
if kind == "scatter":
|
|
462
487
|
scatter_markers = ['o', 's', 'D', 'x', '^', 'v', 'P', '*']
|
|
463
488
|
for key, group, y, xv, category, rows in entries:
|
|
464
489
|
g = group if group_col else None
|
|
465
490
|
active.add((y, g))
|
|
491
|
+
# Use the same editable series keys as the appearance controls.
|
|
492
|
+
# Previously this multi-axis branch ignored all three overrides.
|
|
493
|
+
candidates = (key, f"{g}, {y}" if g is not None else str(y),
|
|
494
|
+
str(g) if g is not None else str(y), str(y))
|
|
495
|
+
def override(mapping, fallback):
|
|
496
|
+
return next((mapping[name] for name in candidates if name in mapping), fallback)
|
|
497
|
+
size = _bounded_float({"value": override(sizes, marker_size)}, "value", marker_size, 0.1, 100)
|
|
498
|
+
alpha = _bounded_float({"value": override(marker_alphas, 1)}, "value", 1, 0, 1)
|
|
499
|
+
default_marker = scatter_markers[x_position[xv] % len(scatter_markers)]
|
|
500
|
+
marker = (_scatter_marker({"value": override(markers, "dot")}, "value")
|
|
501
|
+
if any(name in markers for name in candidates) else default_marker)
|
|
466
502
|
ax.scatter(rows[xv], rows[y], color=color_map[(y, g)],
|
|
467
|
-
marker=
|
|
468
|
-
s=marker_size**2, alpha=.8)
|
|
503
|
+
marker=marker, s=size**2, alpha=alpha)
|
|
469
504
|
else:
|
|
470
505
|
cats = list(pd.unique(df[x].astype(str)))
|
|
471
506
|
combinations = [(g, y) for g in (group_names or ['All observations']) for y in ys]
|
|
472
507
|
n = max(1, len(combinations))
|
|
473
508
|
width = .82 / n
|
|
509
|
+
# Resolve categorical positions once rather than scanning both
|
|
510
|
+
# lists for every multi-axis trace. Keep the original ordering.
|
|
511
|
+
combination_index = {item: i for i, item in enumerate(combinations)}
|
|
512
|
+
category_index = {item: i for i, item in enumerate(cats)}
|
|
474
513
|
from collections import defaultdict
|
|
475
514
|
buckets = defaultdict(list)
|
|
476
515
|
for key, group, y, xv, category, rows in entries:
|
|
477
516
|
g = group if group_col else None
|
|
478
517
|
active.add((y, g))
|
|
479
|
-
idx =
|
|
480
|
-
pos =
|
|
518
|
+
idx = combination_index[(group, y)]
|
|
519
|
+
pos = category_index[category] + (idx - (n - 1)/2)*width
|
|
481
520
|
values = pd.to_numeric(rows[y], errors='coerce').dropna().to_numpy(dtype=float)
|
|
482
521
|
buckets[(pos, y, g)].extend(values)
|
|
483
522
|
for (pos, y, g), values in buckets.items():
|
|
@@ -485,7 +524,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
485
524
|
continue
|
|
486
525
|
color = color_map[(y, g)]
|
|
487
526
|
hatch_options = ['', '/', '\\', 'x', '.', '+', '-']
|
|
488
|
-
hatch_index =
|
|
527
|
+
hatch_index = y_position[y] if multi_y_color_by == 'group' else group_position.get(g, 0)
|
|
489
528
|
hatch = '' if multi_y_color_by in ('none', 'both') else hatch_options[hatch_index % len(hatch_options)]
|
|
490
529
|
if kind == 'bar':
|
|
491
530
|
ax.bar(pos, sum(values), width=width*.92*bar_box_width, color=color, hatch=hatch)
|
|
@@ -584,11 +623,14 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
584
623
|
elif spec["frequency"] is not None and id_col is None:
|
|
585
624
|
line_df = _add_gap_sections(df, x_column=x, group_columns=spec["groups"], frequency=spec["frequency"])
|
|
586
625
|
section_col = "__line_section"
|
|
626
|
+
# Partition the line data once, not once per Y variable. This
|
|
627
|
+
# preserves group order and the existing equality semantics while
|
|
628
|
+
# avoiding repeated full-column masks for multi-Y plots.
|
|
629
|
+
grouped_line_rows = ([(n, line_df.loc[line_df[group_col] == n]) for n in labels]
|
|
630
|
+
if group_col else None)
|
|
587
631
|
for y in ys:
|
|
588
|
-
if
|
|
589
|
-
|
|
590
|
-
else:
|
|
591
|
-
sets = [(str(y) if len(ys) > 1 else "All observations", line_df)]
|
|
632
|
+
sets = (grouped_line_rows if grouped_line_rows is not None else
|
|
633
|
+
[(str(y) if len(ys) > 1 else "All observations", line_df)])
|
|
592
634
|
for name, rows in sets:
|
|
593
635
|
rows = rows.loc[rows[y].notna()]
|
|
594
636
|
if rows.empty:
|
|
@@ -607,16 +607,14 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
607
607
|
_label_keys[field].set(keys)
|
|
608
608
|
|
|
609
609
|
def _label_string(field):
|
|
610
|
-
#
|
|
611
|
-
#
|
|
612
|
-
#
|
|
613
|
-
#
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
return "; ".join(f"{key}={values.get(key, key)}" for key in active
|
|
619
|
-
if values.get(key, key) != key)
|
|
610
|
+
# Pass the complete persisted mapping to the renderer, even
|
|
611
|
+
# before legend keys have been rediscovered on a loaded tab.
|
|
612
|
+
# The editor's key list controls visible inputs only; it must
|
|
613
|
+
# not decide which saved overrides reach Matplotlib.
|
|
614
|
+
# Watch appearance changes so edits invalidate the figure.
|
|
615
|
+
values = _editor_map(field, watch=(field == "legend_labels"))
|
|
616
|
+
return "; ".join(f"{key}={value}" for key, value in values.items()
|
|
617
|
+
if value != key)
|
|
620
618
|
|
|
621
619
|
for _field in _label_fields:
|
|
622
620
|
def _make_editor(field):
|
|
@@ -679,9 +677,11 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
679
677
|
return _editor_map("trace_colors", watch=watch)
|
|
680
678
|
|
|
681
679
|
def _color_string():
|
|
682
|
-
|
|
680
|
+
# The colour-key discovery effect can run after the first
|
|
681
|
+
# restored render. Do not discard persisted overrides while
|
|
682
|
+
# the key list is empty (or temporarily incomplete).
|
|
683
683
|
values = _color_map(watch=True)
|
|
684
|
-
return "; ".join(f"{key}={
|
|
684
|
+
return "; ".join(f"{key}={value}" for key, value in values.items())
|
|
685
685
|
|
|
686
686
|
# Each rendered colour key has a stable swatch button. The popup
|
|
687
687
|
# itself is mounted only on demand, avoiding text-input remounts
|
|
@@ -963,6 +963,18 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
963
963
|
return
|
|
964
964
|
_last_axis_group_choices.set(signature)
|
|
965
965
|
selected = [g for g in current if g in choices]
|
|
966
|
+
# The virtual axis groups are not part of the static selectize
|
|
967
|
+
# choices at mount time. On restoring a saved plot, Shiny may
|
|
968
|
+
# discard them before this effect populates the choices.
|
|
969
|
+
# Restore only eligible saved virtual groups; do not override
|
|
970
|
+
# physical groups or subsequent user changes.
|
|
971
|
+
if previous is None:
|
|
972
|
+
restored = initial_filters.get("group_by", []) or []
|
|
973
|
+
if isinstance(restored, str):
|
|
974
|
+
restored = [restored]
|
|
975
|
+
for name in restored:
|
|
976
|
+
if name in ("variables_x", "variables_y") and name in choices and name not in selected:
|
|
977
|
+
selected.append(name)
|
|
966
978
|
ui.update_selectize("group_by", choices={c: c for c in choices}, selected=selected)
|
|
967
979
|
|
|
968
980
|
@reactive.calc
|
|
@@ -1136,9 +1148,24 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
1136
1148
|
|
|
1137
1149
|
if (selected_plot == "scatter" and "variables_x" in selected_groups
|
|
1138
1150
|
and len(x_variables) > 1):
|
|
1139
|
-
|
|
1140
|
-
|
|
1151
|
+
# Melt copies rather than the original X columns. A source
|
|
1152
|
+
# variable can simultaneously be an X and a Y selection;
|
|
1153
|
+
# removing it here leaves the subsequent Y melt and the
|
|
1154
|
+
# renderer with a stale reference to a missing column.
|
|
1155
|
+
plot_df = plot_df.copy()
|
|
1156
|
+
x_copies = []
|
|
1157
|
+
x_names = {}
|
|
1158
|
+
for index, column in enumerate(x_variables):
|
|
1159
|
+
temporary = f"__plot_x_melt_{index}"
|
|
1160
|
+
while temporary in plot_df.columns:
|
|
1161
|
+
temporary += "_"
|
|
1162
|
+
plot_df[temporary] = plot_df[column]
|
|
1163
|
+
x_copies.append(temporary)
|
|
1164
|
+
x_names[temporary] = column
|
|
1165
|
+
id_columns = [c for c in plot_df.columns if c not in x_copies]
|
|
1166
|
+
plot_df = plot_df.melt(id_vars=id_columns, value_vars=x_copies,
|
|
1141
1167
|
var_name="variables_x", value_name="__axis_x_value")
|
|
1168
|
+
plot_df["variables_x"] = plot_df["variables_x"].map(x_names)
|
|
1142
1169
|
x_column = "__axis_x_value"
|
|
1143
1170
|
x_variables = [x_column]
|
|
1144
1171
|
|
|
@@ -1150,9 +1177,30 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
1150
1177
|
if "variables_y" in selected_groups and len(y_columns) > 1:
|
|
1151
1178
|
# Make the selected Y variable an actual categorical dimension,
|
|
1152
1179
|
# so it works with ordinary grouping and panel partitioning.
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1180
|
+
# A variable may be selected as both scatter X and Y.
|
|
1181
|
+
# Melting the original Y columns would remove it from the
|
|
1182
|
+
# frame, leaving the X axis pointing at a missing column.
|
|
1183
|
+
# Melt temporary Y copies instead, retaining X columns.
|
|
1184
|
+
overlapping = set(y_columns).intersection(x_variables)
|
|
1185
|
+
if overlapping:
|
|
1186
|
+
plot_df = plot_df.copy()
|
|
1187
|
+
copies = []
|
|
1188
|
+
original_names = {}
|
|
1189
|
+
for index, column in enumerate(y_columns):
|
|
1190
|
+
temporary = f"__plot_y_melt_{index}"
|
|
1191
|
+
while temporary in plot_df.columns:
|
|
1192
|
+
temporary += "_"
|
|
1193
|
+
plot_df[temporary] = plot_df[column]
|
|
1194
|
+
copies.append(temporary)
|
|
1195
|
+
original_names[temporary] = column
|
|
1196
|
+
id_columns = [c for c in plot_df.columns if c not in copies]
|
|
1197
|
+
plot_df = plot_df.melt(id_vars=id_columns, value_vars=copies,
|
|
1198
|
+
var_name="variables_y", value_name="__axis_y_value")
|
|
1199
|
+
plot_df["variables_y"] = plot_df["variables_y"].map(original_names)
|
|
1200
|
+
else:
|
|
1201
|
+
id_columns = [c for c in plot_df.columns if c not in y_columns]
|
|
1202
|
+
plot_df = plot_df.melt(id_vars=id_columns, value_vars=y_columns,
|
|
1203
|
+
var_name="variables_y", value_name="__axis_y_value")
|
|
1156
1204
|
y_columns = ["__axis_y_value"]
|
|
1157
1205
|
# The categorical X labels have already been computed above.
|
|
1158
1206
|
# Scatter and bar/box now use the standard single-Y renderer.
|
|
@@ -1185,6 +1233,12 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
1185
1233
|
_composite_labels(plot_df, group_columns)
|
|
1186
1234
|
)
|
|
1187
1235
|
|
|
1236
|
+
# The scatter Y-variable grouping uses a long-form value column.
|
|
1237
|
+
# Keep the spec synchronized with that column even during
|
|
1238
|
+
# reactive transitions between single- and multi-Y selections.
|
|
1239
|
+
if selected_plot == "scatter" and "__axis_y_value" in plot_df.columns:
|
|
1240
|
+
y_columns = ["__axis_y_value"]
|
|
1241
|
+
|
|
1188
1242
|
return {"data": plot_df, "kind": selected_plot, "x": x_column,
|
|
1189
1243
|
"ys": y_columns, "groups": group_columns,
|
|
1190
1244
|
"categories": x_columns if selected_plot in ("box", "bar") else None,
|
|
@@ -1365,6 +1419,12 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
1365
1419
|
# prepared. The effect depends on the plot specification, not on
|
|
1366
1420
|
# appearance inputs, so editing a label or colour cannot reset it.
|
|
1367
1421
|
_last_line_style_keys = [None]
|
|
1422
|
+
_last_scatter_style_keys = [None]
|
|
1423
|
+
# Arm automatic axis-label regeneration only after the restored
|
|
1424
|
+
# X/Y selectors have actually reached their saved values. This
|
|
1425
|
+
# avoids treating intermediate selector initialization as an edit.
|
|
1426
|
+
_restored_axis_selection = [None]
|
|
1427
|
+
_axis_selection_armed = [not bool(initial_filters)]
|
|
1368
1428
|
|
|
1369
1429
|
@reactive.effect
|
|
1370
1430
|
@reactive.event(prepared_plot, lambda: _optional_input("multi_panel", False) if input.group_by() else False,
|
|
@@ -1403,9 +1463,31 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
1403
1463
|
)
|
|
1404
1464
|
if not same_restored_data or "title" not in initial_filters:
|
|
1405
1465
|
ui.update_text("title", value="Summary plot")
|
|
1406
|
-
|
|
1466
|
+
# Preserve restored labels during initialization, then reset
|
|
1467
|
+
# them when the user changes either axis variable selection.
|
|
1468
|
+
# Changes to grouping, panels or appearance do not reset labels.
|
|
1469
|
+
current_axis_selection = (
|
|
1470
|
+
tuple(_optional_input("x", []) or []),
|
|
1471
|
+
tuple(_optional_input("y", []) or []),
|
|
1472
|
+
)
|
|
1473
|
+
saved_axis_selection = (
|
|
1474
|
+
tuple(initial_filters.get("x", []) or []),
|
|
1475
|
+
tuple(initial_filters.get("y", []) or []),
|
|
1476
|
+
)
|
|
1477
|
+
axis_selection_changed = False
|
|
1478
|
+
if not _axis_selection_armed[0]:
|
|
1479
|
+
if current_axis_selection == saved_axis_selection:
|
|
1480
|
+
_axis_selection_armed[0] = True
|
|
1481
|
+
_restored_axis_selection[0] = current_axis_selection
|
|
1482
|
+
elif _restored_axis_selection[0] is None:
|
|
1483
|
+
_restored_axis_selection[0] = current_axis_selection
|
|
1484
|
+
elif current_axis_selection != _restored_axis_selection[0]:
|
|
1485
|
+
axis_selection_changed = True
|
|
1486
|
+
_restored_axis_selection[0] = current_axis_selection
|
|
1487
|
+
|
|
1488
|
+
if axis_selection_changed or "x_axis_label" not in initial_filters:
|
|
1407
1489
|
ui.update_text("x_axis_label", value=defaults["x_label"])
|
|
1408
|
-
if
|
|
1490
|
+
if axis_selection_changed or "y_axis_label" not in initial_filters:
|
|
1409
1491
|
ui.update_text_area("y_axis_label", value=defaults["y_label"])
|
|
1410
1492
|
limits = _axis_bounds_from_spec(
|
|
1411
1493
|
spec,
|
|
@@ -1480,9 +1562,19 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
1480
1562
|
elif kind == "histogram":
|
|
1481
1563
|
ui.update_text_area("hist_alpha", value="\n".join(f"{name}=0.65" for name in names))
|
|
1482
1564
|
elif kind == "scatter":
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1565
|
+
# Preserve user edits across preparation updates; only
|
|
1566
|
+
# synchronize when the actual trace identities change.
|
|
1567
|
+
current_keys = tuple(names)
|
|
1568
|
+
previous_keys = _last_scatter_style_keys[0]
|
|
1569
|
+
_last_scatter_style_keys[0] = current_keys
|
|
1570
|
+
if previous_keys != current_keys:
|
|
1571
|
+
for field, default in (("scatter_size", "7"), ("scatter_marker", "dot"), ("scatter_alpha", "1")):
|
|
1572
|
+
if previous_keys is None and same_restored_data and field in initial_filters:
|
|
1573
|
+
continue
|
|
1574
|
+
with reactive.isolate():
|
|
1575
|
+
existing = _parse_label_map(_optional_input(field, ""))
|
|
1576
|
+
ui.update_text_area(field, value="\n".join(
|
|
1577
|
+
f"{name}={existing.get(name, default)}" for name in names))
|
|
1486
1578
|
if backend == "matplotlib":
|
|
1487
1579
|
panel_variables = ((list(_optional_input("panel_by", []) or []) or selected_groups) if len(selected_groups) > 1
|
|
1488
1580
|
else selected_groups)
|
|
@@ -1967,12 +2059,18 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
1967
2059
|
for field in _label_fields:
|
|
1968
2060
|
with reactive.isolate():
|
|
1969
2061
|
values = dict(_editor_map(field))
|
|
2062
|
+
# A restored tab may snapshot before its dynamic editors
|
|
2063
|
+
# have mounted. Preserve the saved mapping in that case.
|
|
2064
|
+
if not values and initial_filters.get(field):
|
|
2065
|
+
values = dict(_parse_label_map(initial_filters[field]))
|
|
1970
2066
|
result[field] = "; ".join(f"{key}={value}" for key, value in values.items()
|
|
1971
2067
|
if value != key)
|
|
1972
2068
|
with reactive.isolate():
|
|
1973
2069
|
colors = dict(_color_map())
|
|
1974
|
-
|
|
1975
|
-
|
|
2070
|
+
if not colors and initial_filters.get("trace_colors"):
|
|
2071
|
+
colors = dict(_parse_label_map(initial_filters["trace_colors"]))
|
|
2072
|
+
chosen = configured_visibility()
|
|
2073
|
+
visible_keys = tuple(available_elements())
|
|
1976
2074
|
result["trace_colors"] = "; ".join(f"{key}={value}" for key, value in colors.items())
|
|
1977
2075
|
# No available components must not erase visibility in a
|
|
1978
2076
|
# retained configuration (e.g. threshold above maximum).
|
|
@@ -2330,6 +2428,8 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2330
2428
|
ui.input_action_button("close_tab", "Close selected tab", class_="btn-outline-danger"),
|
|
2331
2429
|
ui.input_action_button("retain_plot", "Save selected plot", class_="btn-outline-primary"),
|
|
2332
2430
|
ui.input_action_button("restore_plot", "Load saved plot", class_="btn-outline-primary"),
|
|
2431
|
+
ui.input_action_button("delete_saved_plot", "Delete plot", class_="btn-outline-danger"),
|
|
2432
|
+
ui.input_action_button("save_dataset_pickle", "Save dataset", class_="btn-outline-primary"),
|
|
2333
2433
|
class_="summary-toolbar-actions",
|
|
2334
2434
|
),
|
|
2335
2435
|
# Exactly one app-wide theme picker, outside dynamically added
|
|
@@ -2408,6 +2508,86 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2408
2508
|
except Exception as exc:
|
|
2409
2509
|
ui.notification_show(f"Could not save configuration: {exc}", type="error")
|
|
2410
2510
|
|
|
2511
|
+
@reactive.effect
|
|
2512
|
+
@reactive.event(input.delete_saved_plot)
|
|
2513
|
+
def choose_plot_to_delete():
|
|
2514
|
+
presets = [p for p in manager.saved() if p.get("type") == "plot"]
|
|
2515
|
+
if not presets:
|
|
2516
|
+
ui.notification_show("No saved plots to delete", type="warning")
|
|
2517
|
+
return
|
|
2518
|
+
ui.modal_show(ui.modal(
|
|
2519
|
+
ui.input_select("delete_saved_plot_choice", "Saved plot to delete",
|
|
2520
|
+
choices={p["id"]: p.get("name", p["id"]) for p in presets}),
|
|
2521
|
+
ui.p("This deletes the saved configuration from Summary.plots. Open tabs are not closed."),
|
|
2522
|
+
title="Delete saved plot",
|
|
2523
|
+
footer=ui.div(ui.modal_button("Cancel"),
|
|
2524
|
+
ui.input_action_button("confirm_delete_saved_plot", "Delete", class_="btn-danger")),
|
|
2525
|
+
))
|
|
2526
|
+
|
|
2527
|
+
@reactive.effect
|
|
2528
|
+
@reactive.event(input.confirm_delete_saved_plot)
|
|
2529
|
+
def confirm_delete_saved_plot():
|
|
2530
|
+
try:
|
|
2531
|
+
with reactive.isolate():
|
|
2532
|
+
manager.delete_saved(input.delete_saved_plot_choice())
|
|
2533
|
+
saved_revision.set(saved_revision.get() + 1)
|
|
2534
|
+
ui.modal_remove()
|
|
2535
|
+
ui.notification_show("Saved plot deleted. Save the dataset to persist this change to disk.", type="message")
|
|
2536
|
+
except Exception as exc:
|
|
2537
|
+
ui.notification_show(f"Could not delete saved plot: {exc}", type="error")
|
|
2538
|
+
|
|
2539
|
+
@reactive.effect
|
|
2540
|
+
@reactive.event(input.save_dataset_pickle)
|
|
2541
|
+
def show_save_dataset_dialog():
|
|
2542
|
+
ui.modal_show(ui.modal(
|
|
2543
|
+
ui.p("Save the current Summary, including saved plot configurations, as a pickle file."),
|
|
2544
|
+
ui.input_text("dataset_pickle_path", "Destination path on the computer running Shiny",
|
|
2545
|
+
value="summary.pkl", placeholder="/path/to/drive/summary.pkl"),
|
|
2546
|
+
ui.p("For a mounted drive, enter its full directory path. Existing files will not be overwritten."),
|
|
2547
|
+
title="Save dataset",
|
|
2548
|
+
footer=ui.div(ui.modal_button("Cancel"),
|
|
2549
|
+
ui.input_action_button("confirm_save_dataset_pickle", "Save pickle", class_="btn-primary")),
|
|
2550
|
+
))
|
|
2551
|
+
|
|
2552
|
+
@reactive.effect
|
|
2553
|
+
@reactive.event(input.confirm_save_dataset_pickle)
|
|
2554
|
+
def confirm_save_dataset_pickle():
|
|
2555
|
+
import os
|
|
2556
|
+
import pickle
|
|
2557
|
+
import tempfile
|
|
2558
|
+
from pathlib import Path
|
|
2559
|
+
try:
|
|
2560
|
+
with reactive.isolate():
|
|
2561
|
+
raw = (input.dataset_pickle_path() or "").strip()
|
|
2562
|
+
if not raw:
|
|
2563
|
+
raise ValueError("Provide a destination path")
|
|
2564
|
+
target = Path(raw).expanduser().resolve()
|
|
2565
|
+
if target.suffix.lower() != ".pkl":
|
|
2566
|
+
raise ValueError("The filename must end in .pkl")
|
|
2567
|
+
if not target.parent.is_dir():
|
|
2568
|
+
raise FileNotFoundError(f"Directory does not exist: {target.parent}")
|
|
2569
|
+
if target.exists():
|
|
2570
|
+
raise FileExistsError(f"File already exists: {target}")
|
|
2571
|
+
# Serialize to a temporary file, then publish atomically without
|
|
2572
|
+
# overwriting an existing file. Preserve the original on errors.
|
|
2573
|
+
temp_path = None
|
|
2574
|
+
try:
|
|
2575
|
+
with tempfile.NamedTemporaryFile(mode="wb", dir=target.parent,
|
|
2576
|
+
prefix=".apmtools-", suffix=".tmp",
|
|
2577
|
+
delete=False) as stream:
|
|
2578
|
+
temp_path = Path(stream.name)
|
|
2579
|
+
pickle.dump(manager.summary, stream, protocol=pickle.HIGHEST_PROTOCOL)
|
|
2580
|
+
stream.flush()
|
|
2581
|
+
os.fsync(stream.fileno())
|
|
2582
|
+
os.link(temp_path, target) # fails if target already exists
|
|
2583
|
+
finally:
|
|
2584
|
+
if temp_path is not None:
|
|
2585
|
+
temp_path.unlink(missing_ok=True)
|
|
2586
|
+
ui.modal_remove()
|
|
2587
|
+
ui.notification_show(f"Dataset saved to {target}", type="message", duration=8)
|
|
2588
|
+
except Exception as exc:
|
|
2589
|
+
ui.notification_show(f"Could not save dataset: {exc}", type="error", duration=10)
|
|
2590
|
+
|
|
2411
2591
|
@reactive.effect
|
|
2412
2592
|
@reactive.event(input.restore_plot)
|
|
2413
2593
|
def choose_saved():
|
|
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
|
|
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
|