apmtools 0.2.53__tar.gz → 0.2.55__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.53 → apmtools-0.2.55}/PKG-INFO +1 -1
  2. {apmtools-0.2.53 → apmtools-0.2.55}/pyproject.toml +2 -2
  3. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/classes.py +12 -1
  4. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/plot_app.py +17 -3
  5. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/plot_configs.py +9 -0
  6. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/ui.py +253 -39
  7. {apmtools-0.2.53 → apmtools-0.2.55}/.gitignore +0 -0
  8. {apmtools-0.2.53 → apmtools-0.2.55}/LICENSE +0 -0
  9. {apmtools-0.2.53 → apmtools-0.2.55}/README.md +0 -0
  10. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/__init__.py +0 -0
  11. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/data_processing.py +0 -0
  12. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/functions.py +0 -0
  13. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/legacy.py +0 -0
  14. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/__init__.py +0 -0
  15. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/appearance.py +0 -0
  16. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/color_editor.py +0 -0
  17. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/columns.py +0 -0
  18. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/config.py +0 -0
  19. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/data.py +0 -0
  20. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/elements.py +0 -0
  21. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/export.py +0 -0
  22. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/filter_config.py +0 -0
  23. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/histogram_multi.py +0 -0
  24. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/matplotlib_renderer.py +0 -0
  25. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/multi_axis.py +0 -0
  26. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/multi_y.py +0 -0
  27. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/plotly_renderer.py +0 -0
  28. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/shiny/summary.py +0 -0
  29. {apmtools-0.2.53 → apmtools-0.2.55}/src/apmtools/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.53
3
+ Version: 0.2.55
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.53"
7
+ version = "0.2.55"
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
+ ]
@@ -1011,7 +1011,7 @@ class Group(pd.DataFrame):
1011
1011
 
1012
1012
  class Summary(pd.DataFrame):
1013
1013
 
1014
- _metadata = ['metadata', 'plots']
1014
+ _metadata = ['metadata', 'plots', 'variable', 'description']
1015
1015
 
1016
1016
  @property
1017
1017
  def _constructor(self):
@@ -1030,6 +1030,17 @@ class Summary(pd.DataFrame):
1030
1030
  raise TypeError("Summary.plot must be None or a list")
1031
1031
  super().__setattr__(name, value)
1032
1032
 
1033
+ def __setstate__(self, state):
1034
+ """Restore DataFrame state and default metadata absent in older pickles."""
1035
+ super().__setstate__(state)
1036
+ # Some older pickles contain an instance-level _metadata list, which
1037
+ # masks the corrected class declaration and breaks future copies.
1038
+ self.__dict__.pop("_metadata", None)
1039
+ for name, default in (("plots", None), ("variable", None),
1040
+ ("description", ""), ("metadata", set())):
1041
+ if name not in self.__dict__:
1042
+ object.__setattr__(self, name, default)
1043
+
1033
1044
  def __finalize__(self, other, method=None, **kwargs):
1034
1045
  if isinstance(other, Summary):
1035
1046
  self.variable = getattr(other, "variable", None)
@@ -83,12 +83,26 @@ class PlotApp:
83
83
  import shinyswatch
84
84
 
85
85
  app_ui = ui.page_fluid(
86
+ ui.tags.button("Hide toolbars", type="button", class_="btn btn-sm btn-outline-secondary my-0", id="standalone-toolbar-collapse-toggle", **{"aria-expanded": "true"}),
86
87
  ui.div(
87
- ui.input_action_button("open_dataset", "Open Dataset", class_="btn-primary"),
88
+ ui.input_action_button("open_dataset", "Open Dataset", class_="btn-outline-primary"),
89
+ ui.div(id="standalone-save-dataset-slot"),
88
90
  ui.output_text("dataset_status", inline=True),
89
- class_="d-flex align-items-center gap-3 my-2",
91
+ ui.div(shinyswatch.theme_picker_ui(), class_="summary-app-theme ms-auto"),
92
+ class_="d-flex align-items-center gap-1 my-0 standalone-dataset-toolbar",
90
93
  ),
91
- ui.div(shinyswatch.theme_picker_ui(), class_="summary-app-theme", style="position:absolute;right:1.5rem;top:.5rem;z-index:10"),
94
+ ui.tags.script("""
95
+ (() => {
96
+ const move = () => {
97
+ const slot = document.getElementById('standalone-save-dataset-slot');
98
+ const button = document.querySelector('#summary-dataset-toolbar button');
99
+ if (slot && button && button.parentElement !== slot) slot.appendChild(button);
100
+ };
101
+ new MutationObserver(move).observe(document.body, {childList:true, subtree:true});
102
+ document.addEventListener('DOMContentLoaded', move);
103
+ move();
104
+ })();
105
+ """),
92
106
  ui.output_ui("dataset_workspace"),
93
107
  title="PlotApp",
94
108
  )
@@ -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
 
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
  from .filter_config import filter_config_fields as _filter_config_fields
4
4
  import threading
5
+ import json
5
6
  from datetime import date, datetime, time, timedelta
6
7
  from collections.abc import Sequence
7
8
  import numpy as np
@@ -607,16 +608,14 @@ if (!window.__apmColorPickerInstalled) {
607
608
  _label_keys[field].set(keys)
608
609
 
609
610
  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)
611
+ # Pass the complete persisted mapping to the renderer, even
612
+ # before legend keys have been rediscovered on a loaded tab.
613
+ # The editor's key list controls visible inputs only; it must
614
+ # not decide which saved overrides reach Matplotlib.
615
+ # Watch appearance changes so edits invalidate the figure.
616
+ values = _editor_map(field, watch=(field == "legend_labels"))
617
+ return "; ".join(f"{key}={value}" for key, value in values.items()
618
+ if value != key)
620
619
 
621
620
  for _field in _label_fields:
622
621
  def _make_editor(field):
@@ -679,9 +678,11 @@ if (!window.__apmColorPickerInstalled) {
679
678
  return _editor_map("trace_colors", watch=watch)
680
679
 
681
680
  def _color_string():
682
- keys = _color_keys.get()
681
+ # The colour-key discovery effect can run after the first
682
+ # restored render. Do not discard persisted overrides while
683
+ # the key list is empty (or temporarily incomplete).
683
684
  values = _color_map(watch=True)
684
- return "; ".join(f"{key}={values[key]}" for key in keys if key in values)
685
+ return "; ".join(f"{key}={value}" for key, value in values.items())
685
686
 
686
687
  # Each rendered colour key has a stable swatch button. The popup
687
688
  # itself is mounted only on demand, avoiding text-input remounts
@@ -1420,6 +1421,11 @@ if (!window.__apmColorPickerInstalled) {
1420
1421
  # appearance inputs, so editing a label or colour cannot reset it.
1421
1422
  _last_line_style_keys = [None]
1422
1423
  _last_scatter_style_keys = [None]
1424
+ # Arm automatic axis-label regeneration only after the restored
1425
+ # X/Y selectors have actually reached their saved values. This
1426
+ # avoids treating intermediate selector initialization as an edit.
1427
+ _restored_axis_selection = [None]
1428
+ _axis_selection_armed = [not bool(initial_filters)]
1423
1429
 
1424
1430
  @reactive.effect
1425
1431
  @reactive.event(prepared_plot, lambda: _optional_input("multi_panel", False) if input.group_by() else False,
@@ -1458,9 +1464,31 @@ if (!window.__apmColorPickerInstalled) {
1458
1464
  )
1459
1465
  if not same_restored_data or "title" not in initial_filters:
1460
1466
  ui.update_text("title", value="Summary plot")
1461
- if not same_restored_data or "x_axis_label" not in initial_filters:
1467
+ # Preserve restored labels during initialization, then reset
1468
+ # them when the user changes either axis variable selection.
1469
+ # Changes to grouping, panels or appearance do not reset labels.
1470
+ current_axis_selection = (
1471
+ tuple(_optional_input("x", []) or []),
1472
+ tuple(_optional_input("y", []) or []),
1473
+ )
1474
+ saved_axis_selection = (
1475
+ tuple(initial_filters.get("x", []) or []),
1476
+ tuple(initial_filters.get("y", []) or []),
1477
+ )
1478
+ axis_selection_changed = False
1479
+ if not _axis_selection_armed[0]:
1480
+ if current_axis_selection == saved_axis_selection:
1481
+ _axis_selection_armed[0] = True
1482
+ _restored_axis_selection[0] = current_axis_selection
1483
+ elif _restored_axis_selection[0] is None:
1484
+ _restored_axis_selection[0] = current_axis_selection
1485
+ elif current_axis_selection != _restored_axis_selection[0]:
1486
+ axis_selection_changed = True
1487
+ _restored_axis_selection[0] = current_axis_selection
1488
+
1489
+ if axis_selection_changed or "x_axis_label" not in initial_filters:
1462
1490
  ui.update_text("x_axis_label", value=defaults["x_label"])
1463
- if not same_restored_data or "y_axis_label" not in initial_filters:
1491
+ if axis_selection_changed or "y_axis_label" not in initial_filters:
1464
1492
  ui.update_text_area("y_axis_label", value=defaults["y_label"])
1465
1493
  limits = _axis_bounds_from_spec(
1466
1494
  spec,
@@ -1501,6 +1529,21 @@ if (!window.__apmColorPickerInstalled) {
1501
1529
  panel_bounds = {name: _pad_axis_bounds(bounds) for name, bounds in panel_bounds.items()}
1502
1530
  limits = {key: _format_panel_bounds(panel_bounds, key) for key in limits}
1503
1531
  for field, value in limits.items():
1532
+ # A restored plot's explicit axis bounds are authoritative.
1533
+ # The preparation observer fires after the saved inputs mount;
1534
+ # overwriting them here caused the saved limits to flash and
1535
+ # then revert to automatically calculated limits. Continue
1536
+ # recalculating defaults when the user changes X/Y variables.
1537
+ # Restoration is asynchronous: the group, aggregation and
1538
+ # plot selectors can briefly disagree with the saved values
1539
+ # while mounting. That is NOT a user edit and must never
1540
+ # erase a persisted bound. Release the guard only when an
1541
+ # actual X/Y selection change has been observed after the
1542
+ # saved selectors were fully initialized.
1543
+ if (not axis_selection_changed
1544
+ and field in initial_filters
1545
+ and str(initial_filters[field]).strip()):
1546
+ continue
1504
1547
  if backend == "matplotlib":
1505
1548
  ui.update_text_area(field, value=value)
1506
1549
  else:
@@ -1626,6 +1669,12 @@ if (!window.__apmColorPickerInstalled) {
1626
1669
  @reactive.effect
1627
1670
  @reactive.event(input.plot_type, ignore_init=True)
1628
1671
  def reset_on_plot_type_change():
1672
+ # During a restored tab's initial mount, Shiny can report a
1673
+ # transient plot type. Do not clear persisted axis bounds (or
1674
+ # other appearance settings) until the restored selectors have
1675
+ # settled and a real plot-type change can be observed.
1676
+ if initial_filters and not _axis_selection_armed[0]:
1677
+ return
1629
1678
  # Reset every plot-specific selection when the plot type changes.
1630
1679
  for _i in range(max_subset_stages):
1631
1680
  ui.update_select(f"subset_column_{_i}", selected="")
@@ -1754,6 +1803,16 @@ if (!window.__apmColorPickerInstalled) {
1754
1803
  labels = [txt.get_text() for leg in legends for txt in leg.get_texts()]
1755
1804
  excluded = ("Y variable", "Grouping", "Legend", "All observations")
1756
1805
  labels = [label for label in labels if label.strip() and label not in excluded]
1806
+ # A faceted plot can temporarily expose no legend while Shiny
1807
+ # swaps axes (or when the panel variable consumes the only
1808
+ # grouping dimension). Do not clear the editor identities in
1809
+ # that transient state: remounting the text inputs on every
1810
+ # panel transition can disconnect their reactive watchers.
1811
+ with reactive.isolate():
1812
+ panel_mode = (backend == "matplotlib" and
1813
+ bool(_optional_input("multi_panel", False)))
1814
+ if panel_mode and not labels and known_keys:
1815
+ return
1757
1816
  # If this is merely a relabelled rendering of the existing
1758
1817
  # traces, do not rediscover/rename their identities.
1759
1818
  expected = [mapping.get(key, key) for key in known_keys]
@@ -2032,12 +2091,18 @@ if (!window.__apmColorPickerInstalled) {
2032
2091
  for field in _label_fields:
2033
2092
  with reactive.isolate():
2034
2093
  values = dict(_editor_map(field))
2094
+ # A restored tab may snapshot before its dynamic editors
2095
+ # have mounted. Preserve the saved mapping in that case.
2096
+ if not values and initial_filters.get(field):
2097
+ values = dict(_parse_label_map(initial_filters[field]))
2035
2098
  result[field] = "; ".join(f"{key}={value}" for key, value in values.items()
2036
2099
  if value != key)
2037
2100
  with reactive.isolate():
2038
2101
  colors = dict(_color_map())
2039
- chosen = configured_visibility()
2040
- visible_keys = tuple(available_elements())
2102
+ if not colors and initial_filters.get("trace_colors"):
2103
+ colors = dict(_parse_label_map(initial_filters["trace_colors"]))
2104
+ chosen = configured_visibility()
2105
+ visible_keys = tuple(available_elements())
2041
2106
  result["trace_colors"] = "; ".join(f"{key}={value}" for key, value in colors.items())
2042
2107
  # No available components must not erase visibility in a
2043
2108
  # retained configuration (e.g. threshold above maximum).
@@ -2337,17 +2402,34 @@ if (!window.__apmColorPickerInstalled) {
2337
2402
  .summary-plot-container {width:100%; height:auto; aspect-ratio: 2 / 1; min-width:0; position:relative;}
2338
2403
  .summary-plot-container > * {width:100% !important;}
2339
2404
  .summary-plot-container .shiny-plot-output {min-height:400px;}
2340
- /* Keep the shared app theme picker right-aligned in the top toolbar.
2341
- This selector is independent of each plot's Plot appearance theme. */
2342
- .summary-top-toolbar {display:flex; align-items:flex-end; gap:0.75rem;
2343
- flex-wrap:wrap; margin-bottom:1rem;}
2344
- .summary-toolbar-actions {display:flex; gap:0.5rem; flex-wrap:wrap; align-items:center;}
2345
- .summary-app-theme {margin-left:auto; width:220px; max-width:100%;}
2405
+ .summary-top-toolbar {display:flex; align-items:center; gap:0.5rem;
2406
+ flex-wrap:nowrap; margin-bottom:0; min-width:0;}
2407
+ .summary-toolbar-actions {display:flex; gap:0.4rem; flex-wrap:nowrap;
2408
+ align-items:center; min-width:0; overflow-x:auto;}
2409
+ .summary-toolbar-actions .btn {white-space:nowrap;}
2410
+ .summary-toolbar-right {display:flex; gap:0.4rem; align-items:center;
2411
+ margin-left:auto; flex-shrink:0;}
2412
+ .summary-app-theme {width:200px; flex-shrink:0;}
2346
2413
  .summary-app-theme .shiny-input-container {width:100%; margin-bottom:0;}
2347
- @media (max-width: 480px) {
2348
- .summary-app-theme {width:100%;}
2349
- }
2350
- """),
2414
+ .summary-dataset-toolbar {display:flex; align-items:center; gap:0.5rem;
2415
+ margin-bottom:0; min-width:0;}
2416
+ .summary-dataset-toolbar .btn {white-space:nowrap;}
2417
+ .summary-dataset-toolbar .summary-app-theme {margin-left:auto;}
2418
+ .summary-toolbar-collapse-toggle {margin-bottom:0; padding-top:0.12rem; padding-bottom:0.12rem;}
2419
+ .summary-dataset-toolbar .shiny-input-container,
2420
+ .summary-toolbar-actions .shiny-input-container {margin-bottom:0 !important;}
2421
+ .summary-dataset-toolbar .btn, .summary-toolbar-actions .btn {padding:0.22rem 0.65rem; font-size:0.875rem; line-height:1.5;}
2422
+ .summary-dataset-toolbar .shiny-input-container,
2423
+ .standalone-dataset-toolbar .shiny-input-container {width:auto !important; flex:0 0 auto;}
2424
+ .standalone-dataset-toolbar {margin-bottom:0 !important; gap:0.35rem !important;}
2425
+ .standalone-dataset-toolbar .btn {padding:0.22rem 0.65rem; font-size:0.875rem; line-height:1.5;}
2426
+ .summary-dataset-toolbar {gap:0.35rem;}
2427
+ .summary-toolbar-collapsed .summary-dataset-toolbar,
2428
+ .summary-toolbar-collapsed .summary-toolbar-actions {display:none !important;}
2429
+ body:has(#standalone-toolbar-collapse-toggle) .summary-toolbar-collapse-toggle {display:none;}
2430
+ body:has(#standalone-toolbar-collapse-toggle).summary-toolbar-collapsed
2431
+ .standalone-dataset-toolbar {display:none !important;}
2432
+ """),
2351
2433
  ui.tags.script("""
2352
2434
  (() => {
2353
2435
  // A Plotly relayout mutates its DOM. Observing all descendants and
@@ -2385,22 +2467,34 @@ if (!window.__apmColorPickerInstalled) {
2385
2467
  }).observe(document.body, {childList:true, subtree:true});
2386
2468
  document.addEventListener('shown.bs.tab', () => requestAnimationFrame(install));
2387
2469
  install();
2470
+ // Delegated listener also works when a dataset workspace is replaced.
2471
+ document.addEventListener('click', (event) => {
2472
+ const toggle = event.target.closest('#summary-toolbar-collapse-toggle, #standalone-toolbar-collapse-toggle');
2473
+ if (!toggle) return;
2474
+ const collapsed = document.body.classList.toggle('summary-toolbar-collapsed');
2475
+ document.querySelectorAll('#summary-toolbar-collapse-toggle, #standalone-toolbar-collapse-toggle').forEach(button => {
2476
+ button.textContent = collapsed ? 'Show toolbars' : 'Hide toolbars';
2477
+ button.setAttribute('aria-expanded', String(!collapsed));
2478
+ });
2479
+ });
2388
2480
  })();
2389
2481
  """),
2390
2482
  ui.div(
2483
+ ui.tags.button("Hide toolbars", type="button", class_="btn btn-sm btn-outline-secondary summary-toolbar-collapse-toggle", id="summary-toolbar-collapse-toggle", **{"aria-expanded": "true"}),
2391
2484
  ui.div(
2392
- ui.div(
2393
- ui.input_action_button("add_tab", "+ New plot", class_="btn-primary"),
2394
- ui.input_action_button("add_table", "+ New table", class_="btn-primary"),
2395
- ui.input_action_button("close_tab", "Close selected tab", class_="btn-outline-danger"),
2396
- ui.input_action_button("retain_plot", "Save selected plot", class_="btn-outline-primary"),
2397
- ui.input_action_button("restore_plot", "Load saved plot", class_="btn-outline-primary"),
2398
- class_="summary-toolbar-actions",
2399
- ),
2400
- # Exactly one app-wide theme picker, outside dynamically added
2401
- # plot modules. shinyswatch provides its full theme catalogue.
2402
- ui.div(shinyswatch.theme_picker_ui(), class_="summary-app-theme") if include_theme_picker else ui.div(class_="summary-app-theme"),
2403
- class_="summary-top-toolbar",
2485
+ ui.input_action_button("save_dataset_pickle", "Save dataset", class_="btn-outline-primary"),
2486
+ ui.div(shinyswatch.theme_picker_ui(), class_="summary-app-theme") if include_theme_picker else ui.div(),
2487
+ class_="summary-dataset-toolbar",
2488
+ id="summary-dataset-toolbar",
2489
+ ),
2490
+ ui.div(
2491
+ ui.input_action_button("add_tab", "New plot", class_="btn-primary"),
2492
+ ui.input_action_button("add_table", "New table", class_="btn-primary"),
2493
+ ui.input_action_button("retain_plot", "Save Plot", class_="btn-outline-primary"),
2494
+ ui.input_action_button("restore_plot", "Load Plot", class_="btn-outline-primary"),
2495
+ ui.input_action_button("delete_saved_plot", "Delete Plot", class_="btn-outline-danger"),
2496
+ ui.input_action_button("close_tab", "Close tab", class_="btn-outline-danger"),
2497
+ class_="summary-toolbar-actions summary-top-toolbar",
2404
2498
  ),
2405
2499
  ui.tags.style("""
2406
2500
  .summary-info-content {max-width: 980px; margin: 1.2rem auto 3rem;
@@ -2468,11 +2562,130 @@ if (!window.__apmColorPickerInstalled) {
2468
2562
  manager.update(selected, tab_filters[selected]())
2469
2563
  manager.retain(selected, input.retained_plot_name())
2470
2564
  saved_revision.set(saved_revision.get() + 1)
2565
+ # Update the existing tab label without remounting its Shiny module.
2566
+ # Replacing the nav panel would destroy the live plot controls.
2567
+ saved_label = str(input.retained_plot_name()).strip()
2568
+ ui.insert_ui(
2569
+ ui.tags.script(
2570
+ "(function(){const tab=" + json.dumps(selected) +
2571
+ ", label=" + json.dumps(saved_label) +
2572
+ "; const a=document.querySelector('[data-value=\"'+CSS.escape(tab)+'\"]');" +
2573
+ "if(a){const span=a.querySelector('span'); if(span)span.textContent=label; else a.textContent=label;}" +
2574
+ "})();"
2575
+ ),
2576
+ selector="body", where="beforeEnd",
2577
+ )
2471
2578
  ui.modal_remove()
2472
2579
  ui.notification_show("Plot configuration retained in Summary.plots", type="message")
2473
2580
  except Exception as exc:
2474
2581
  ui.notification_show(f"Could not save configuration: {exc}", type="error")
2475
2582
 
2583
+ @reactive.effect
2584
+ @reactive.event(input.delete_saved_plot)
2585
+ def choose_plot_to_delete():
2586
+ presets = [p for p in manager.saved() if p.get("type") == "plot"]
2587
+ if not presets:
2588
+ ui.notification_show("No saved plots to delete", type="warning")
2589
+ return
2590
+ ui.modal_show(ui.modal(
2591
+ ui.input_select("delete_saved_plot_choice", "Saved plot to delete",
2592
+ choices={p["id"]: p.get("name", p["id"]) for p in presets}),
2593
+ ui.p("This deletes the saved configuration from Summary.plots. Open tabs are not closed."),
2594
+ title="Delete saved plot",
2595
+ footer=ui.div(ui.modal_button("Cancel"),
2596
+ ui.input_action_button("confirm_delete_saved_plot", "Delete", class_="btn-danger")),
2597
+ ))
2598
+
2599
+ @reactive.effect
2600
+ @reactive.event(input.confirm_delete_saved_plot)
2601
+ def confirm_delete_saved_plot():
2602
+ try:
2603
+ with reactive.isolate():
2604
+ manager.delete_saved(input.delete_saved_plot_choice())
2605
+ saved_revision.set(saved_revision.get() + 1)
2606
+ ui.modal_remove()
2607
+ ui.notification_show("Saved plot deleted. Save the dataset to persist this change to disk.", type="message")
2608
+ except Exception as exc:
2609
+ ui.notification_show(f"Could not delete saved plot: {exc}", type="error")
2610
+
2611
+ @reactive.effect
2612
+ @reactive.event(input.save_dataset_pickle)
2613
+ def show_save_dataset_dialog():
2614
+ ui.modal_show(ui.modal(
2615
+ ui.p("Save the current Summary, including saved plot configurations, as a pickle file."),
2616
+ ui.input_text("dataset_pickle_path", "Destination path on the computer running Shiny",
2617
+ value="summary.pkl", placeholder="/path/to/drive/summary.pkl"),
2618
+ ui.p("For a mounted drive, enter its full directory path. Existing files can be overwritten if you confirm."),
2619
+ ui.input_checkbox("overwrite_dataset_pickle", "Overwrite existing file", value=False),
2620
+ title="Save dataset",
2621
+ footer=ui.div(ui.modal_button("Cancel"),
2622
+ ui.input_action_button("confirm_save_dataset_pickle", "Save pickle", class_="btn-primary")),
2623
+ ))
2624
+
2625
+ @reactive.effect
2626
+ @reactive.event(input.confirm_save_dataset_pickle)
2627
+ def confirm_save_dataset_pickle():
2628
+ import os
2629
+ import pickle
2630
+ import tempfile
2631
+ from pathlib import Path
2632
+ try:
2633
+ with reactive.isolate():
2634
+ raw = (input.dataset_pickle_path() or "").strip()
2635
+ if not raw:
2636
+ raise ValueError("Provide a destination path")
2637
+ target = Path(raw).expanduser().resolve()
2638
+ if target.suffix.lower() != ".pkl":
2639
+ raise ValueError("The filename must end in .pkl")
2640
+ if not target.parent.is_dir():
2641
+ raise FileNotFoundError(f"Directory does not exist: {target.parent}")
2642
+ overwrite = bool(input.overwrite_dataset_pickle())
2643
+ if target.exists() and not overwrite:
2644
+ raise FileExistsError(f"File already exists: {target}. Check Overwrite existing file to replace it.")
2645
+ if target.exists() and not target.is_file():
2646
+ raise ValueError(f"Destination is not a regular file: {target}")
2647
+ # Serialize to a temporary file, then publish atomically without
2648
+ # overwriting an existing file. Preserve the original on errors.
2649
+ temp_path = None
2650
+ try:
2651
+ with tempfile.NamedTemporaryFile(mode="wb", dir=target.parent,
2652
+ prefix=".apmtools-", suffix=".tmp",
2653
+ delete=False) as stream:
2654
+ temp_path = Path(stream.name)
2655
+ # The manager owns retained plot presets. Never serialize
2656
+ # the working plotting frame, which may have stale metadata.
2657
+ source = manager.summary
2658
+ retained = manager.saved()
2659
+ if not hasattr(source, "plots"):
2660
+ raise RuntimeError("Source Summary has no plots attribute")
2661
+ # Serialize an independent snapshot so that a concurrent
2662
+ # Shiny event cannot mutate metadata during the write.
2663
+ snapshot = source.copy(deep=False)
2664
+ snapshot.plots = retained
2665
+ pickle.dump(snapshot, stream, protocol=pickle.HIGHEST_PROTOCOL)
2666
+ stream.flush()
2667
+ os.fsync(stream.fileno())
2668
+ # Verify what was actually written before publishing it.
2669
+ with temp_path.open("rb") as check_stream:
2670
+ restored = pickle.load(check_stream)
2671
+ restored_plots = getattr(restored, "plots", None)
2672
+ if restored_plots != retained:
2673
+ raise RuntimeError(
2674
+ "Pickle verification failed: saved plot configurations "
2675
+ "were not preserved; no dataset file was published"
2676
+ )
2677
+ if overwrite:
2678
+ os.replace(temp_path, target) # atomic replacement after verification
2679
+ else:
2680
+ os.link(temp_path, target) # never overwrite unless explicitly requested
2681
+ finally:
2682
+ if temp_path is not None:
2683
+ temp_path.unlink(missing_ok=True)
2684
+ ui.modal_remove()
2685
+ ui.notification_show(f"Dataset saved to {target}", type="message", duration=8)
2686
+ except Exception as exc:
2687
+ ui.notification_show(f"Could not save dataset: {exc}", type="error", duration=10)
2688
+
2476
2689
  @reactive.effect
2477
2690
  @reactive.event(input.restore_plot)
2478
2691
  def choose_saved():
@@ -2498,7 +2711,8 @@ if (!window.__apmColorPickerInstalled) {
2498
2711
  number = next_tab.get() + 1
2499
2712
  tab_id = f"plot_{number}"
2500
2713
  inherited = manager.restore(preset_id, tab_id)
2501
- panel = ui.nav_panel(f"Plot {number}", plot_tab_ui(tab_id, inherited), value=tab_id)
2714
+ saved_name = next((p.get("name") for p in manager.saved() if p.get("id") == preset_id), None)
2715
+ panel = ui.nav_panel(saved_name or f"Plot {number}", plot_tab_ui(tab_id, inherited), value=tab_id)
2502
2716
  tab_filters[tab_id] = plot_tab_server(tab_id, inherited, config_tab_id=tab_id)
2503
2717
  register_draft(tab_id, tab_filters[tab_id])
2504
2718
  next_tab.set(number)
File without changes
File without changes
File without changes