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.
Files changed (29) hide show
  1. {apmtools-0.2.52 → apmtools-0.2.54}/PKG-INFO +1 -1
  2. {apmtools-0.2.52 → apmtools-0.2.54}/pyproject.toml +2 -2
  3. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/plot_configs.py +9 -0
  4. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/matplotlib_renderer.py +56 -14
  5. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/ui.py +204 -24
  6. {apmtools-0.2.52 → apmtools-0.2.54}/.gitignore +0 -0
  7. {apmtools-0.2.52 → apmtools-0.2.54}/LICENSE +0 -0
  8. {apmtools-0.2.52 → apmtools-0.2.54}/README.md +0 -0
  9. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/__init__.py +0 -0
  10. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/classes.py +0 -0
  11. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/data_processing.py +0 -0
  12. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/functions.py +0 -0
  13. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/legacy.py +0 -0
  14. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/plot_app.py +0 -0
  15. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/__init__.py +0 -0
  16. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/appearance.py +0 -0
  17. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/color_editor.py +0 -0
  18. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/columns.py +0 -0
  19. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/config.py +0 -0
  20. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/data.py +0 -0
  21. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/elements.py +0 -0
  22. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/export.py +0 -0
  23. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/filter_config.py +0 -0
  24. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/histogram_multi.py +0 -0
  25. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/multi_axis.py +0 -0
  26. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/multi_y.py +0 -0
  27. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/plotly_renderer.py +0 -0
  28. {apmtools-0.2.52 → apmtools-0.2.54}/src/apmtools/shiny/summary.py +0 -0
  29. {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.52
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.52"
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
- for extra in list(axes.flat)[len(partitions):]:
408
- extra.set_visible(False)
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 list(axes.flat)[:len(partitions)]:
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, list(axes.flat)[:len(partitions)], legend_font_size, legend_columns)
438
+ _fit_inside_legends(fig, active_axes, legend_font_size, legend_columns)
418
439
  else:
419
- _place_outside_legend(fig, list(axes.flat)[:len(partitions)],
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=scatter_markers[x_names.index(xv) % len(scatter_markers)],
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 = combinations.index((group, y))
480
- pos = cats.index(category) + (idx - (n - 1)/2)*width
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 = ys.index(y) if multi_y_color_by == 'group' else (group_names.index(g) if g in group_names else 0)
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 group_col:
589
- sets = [(n, line_df.loc[line_df[group_col] == n]) for n in labels]
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
- # Internal adapter for the existing renderer/export APIs.
611
- # Label discovery is downstream of figure rendering. Do not
612
- # subscribe the figure to its own discovered key set: this
613
- # otherwise forms plot -> legend keys -> plot feedback cycles.
614
- with reactive.isolate():
615
- keys = _label_keys[field].get()
616
- values = _editor_map(field)
617
- active = keys if keys else tuple(values)
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
- keys = _color_keys.get()
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}={values[key]}" for key in keys if key in values)
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
- id_columns = [c for c in plot_df.columns if c not in x_variables]
1140
- plot_df = plot_df.melt(id_vars=id_columns, value_vars=x_variables,
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
- id_columns = [c for c in plot_df.columns if c not in y_columns]
1154
- plot_df = plot_df.melt(id_vars=id_columns, value_vars=y_columns,
1155
- var_name="variables_y", value_name="__axis_y_value")
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
- if not same_restored_data or "x_axis_label" not in initial_filters:
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 not same_restored_data or "y_axis_label" not in initial_filters:
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
- ui.update_text_area("scatter_size", value="\n".join(f"{name}=7" for name in names))
1484
- ui.update_text_area("scatter_marker", value="\n".join(f"{name}=dot" for name in names))
1485
- ui.update_text_area("scatter_alpha", value="\n".join(f"{name}=1" for name in names))
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
- chosen = configured_visibility()
1975
- visible_keys = tuple(available_elements())
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