apmtools 0.2.53__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.53 → apmtools-0.2.54}/PKG-INFO +1 -1
  2. {apmtools-0.2.53 → apmtools-0.2.54}/pyproject.toml +2 -2
  3. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/plot_configs.py +9 -0
  4. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/ui.py +131 -16
  5. {apmtools-0.2.53 → apmtools-0.2.54}/.gitignore +0 -0
  6. {apmtools-0.2.53 → apmtools-0.2.54}/LICENSE +0 -0
  7. {apmtools-0.2.53 → apmtools-0.2.54}/README.md +0 -0
  8. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/__init__.py +0 -0
  9. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/classes.py +0 -0
  10. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/data_processing.py +0 -0
  11. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/functions.py +0 -0
  12. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/legacy.py +0 -0
  13. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/plot_app.py +0 -0
  14. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/__init__.py +0 -0
  15. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/appearance.py +0 -0
  16. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/color_editor.py +0 -0
  17. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/columns.py +0 -0
  18. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/config.py +0 -0
  19. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/data.py +0 -0
  20. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/elements.py +0 -0
  21. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/export.py +0 -0
  22. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/filter_config.py +0 -0
  23. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/histogram_multi.py +0 -0
  24. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/matplotlib_renderer.py +0 -0
  25. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/multi_axis.py +0 -0
  26. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/multi_y.py +0 -0
  27. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/plotly_renderer.py +0 -0
  28. {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/summary.py +0 -0
  29. {apmtools-0.2.53 → 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.53
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.53"
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
 
@@ -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
@@ -1420,6 +1420,11 @@ if (!window.__apmColorPickerInstalled) {
1420
1420
  # appearance inputs, so editing a label or colour cannot reset it.
1421
1421
  _last_line_style_keys = [None]
1422
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)]
1423
1428
 
1424
1429
  @reactive.effect
1425
1430
  @reactive.event(prepared_plot, lambda: _optional_input("multi_panel", False) if input.group_by() else False,
@@ -1458,9 +1463,31 @@ if (!window.__apmColorPickerInstalled) {
1458
1463
  )
1459
1464
  if not same_restored_data or "title" not in initial_filters:
1460
1465
  ui.update_text("title", value="Summary plot")
1461
- 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:
1462
1489
  ui.update_text("x_axis_label", value=defaults["x_label"])
1463
- 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:
1464
1491
  ui.update_text_area("y_axis_label", value=defaults["y_label"])
1465
1492
  limits = _axis_bounds_from_spec(
1466
1493
  spec,
@@ -2032,12 +2059,18 @@ if (!window.__apmColorPickerInstalled) {
2032
2059
  for field in _label_fields:
2033
2060
  with reactive.isolate():
2034
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]))
2035
2066
  result[field] = "; ".join(f"{key}={value}" for key, value in values.items()
2036
2067
  if value != key)
2037
2068
  with reactive.isolate():
2038
2069
  colors = dict(_color_map())
2039
- chosen = configured_visibility()
2040
- 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())
2041
2074
  result["trace_colors"] = "; ".join(f"{key}={value}" for key, value in colors.items())
2042
2075
  # No available components must not erase visibility in a
2043
2076
  # retained configuration (e.g. threshold above maximum).
@@ -2395,6 +2428,8 @@ if (!window.__apmColorPickerInstalled) {
2395
2428
  ui.input_action_button("close_tab", "Close selected tab", class_="btn-outline-danger"),
2396
2429
  ui.input_action_button("retain_plot", "Save selected plot", class_="btn-outline-primary"),
2397
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"),
2398
2433
  class_="summary-toolbar-actions",
2399
2434
  ),
2400
2435
  # Exactly one app-wide theme picker, outside dynamically added
@@ -2473,6 +2508,86 @@ if (!window.__apmColorPickerInstalled) {
2473
2508
  except Exception as exc:
2474
2509
  ui.notification_show(f"Could not save configuration: {exc}", type="error")
2475
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
+
2476
2591
  @reactive.effect
2477
2592
  @reactive.event(input.restore_plot)
2478
2593
  def choose_saved():
File without changes
File without changes
File without changes