apmtools 0.2.51__tar.gz → 0.2.52__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.51 → apmtools-0.2.52}/PKG-INFO +1 -1
  2. {apmtools-0.2.51 → apmtools-0.2.52}/pyproject.toml +1 -1
  3. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/classes.py +3 -3
  4. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/plot_app.py +7 -2
  5. apmtools-0.2.52/src/apmtools/plot_configs.py +143 -0
  6. apmtools-0.2.52/src/apmtools/shiny/filter_config.py +22 -0
  7. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/matplotlib_renderer.py +8 -2
  8. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/multi_y.py +6 -0
  9. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/ui.py +379 -107
  10. {apmtools-0.2.51 → apmtools-0.2.52}/.gitignore +0 -0
  11. {apmtools-0.2.51 → apmtools-0.2.52}/LICENSE +0 -0
  12. {apmtools-0.2.51 → apmtools-0.2.52}/README.md +0 -0
  13. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/__init__.py +0 -0
  14. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/data_processing.py +0 -0
  15. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/functions.py +0 -0
  16. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/legacy.py +0 -0
  17. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/__init__.py +0 -0
  18. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/appearance.py +0 -0
  19. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/color_editor.py +0 -0
  20. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/columns.py +0 -0
  21. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/config.py +0 -0
  22. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/data.py +0 -0
  23. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/elements.py +0 -0
  24. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/export.py +0 -0
  25. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/histogram_multi.py +0 -0
  26. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/multi_axis.py +0 -0
  27. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/plotly_renderer.py +0 -0
  28. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/summary.py +0 -0
  29. {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.51
3
+ Version: 0.2.52
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.51"
7
+ version = "0.2.52"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -1011,7 +1011,7 @@ class Group(pd.DataFrame):
1011
1011
 
1012
1012
  class Summary(pd.DataFrame):
1013
1013
 
1014
- _metadata = ['metadata', 'plot']
1014
+ _metadata = ['metadata', 'plots']
1015
1015
 
1016
1016
  @property
1017
1017
  def _constructor(self):
@@ -1026,7 +1026,7 @@ class Summary(pd.DataFrame):
1026
1026
  self.plots = None
1027
1027
 
1028
1028
  def __setattr__(self, name, value):
1029
- if name == "plot" and value is not None and not isinstance(value, list):
1029
+ if name == "plots" and value is not None and not isinstance(value, list):
1030
1030
  raise TypeError("Summary.plot must be None or a list")
1031
1031
  super().__setattr__(name, value)
1032
1032
 
@@ -1035,7 +1035,7 @@ class Summary(pd.DataFrame):
1035
1035
  self.variable = getattr(other, "variable", None)
1036
1036
  self.metadata = getattr(other, "metadata", set()).copy()
1037
1037
  self.description = getattr(other, "description", "")
1038
- self.plots = getattr(other, "plot", None)
1038
+ self.plots = getattr(other, "plots", None)
1039
1039
 
1040
1040
  return self
1041
1041
 
@@ -71,11 +71,15 @@ class PlotApp:
71
71
  # Do not change process-wide pandas settings here.
72
72
  # With older pandas and CoW disabled, a shallow copy may share writes.
73
73
  self.summary = summary.copy(deep=False)
74
+ # Explicitly retained presets belong to the source Summary. Avoid
75
+ # mutating a shared list accidentally through the working frame.
76
+ self.summary.plots = None if getattr(summary, "plots", None) is None else list(summary.plots)
74
77
 
75
78
  def create_app(self):
76
79
  """Build an app whose complete plotting workspace is replaced on load."""
77
80
  from shiny import App, module, reactive, render, ui
78
81
  from .shiny.ui import _build_summary_ui_server
82
+ from .plot_configs import PlotConfigManager
79
83
  import shinyswatch
80
84
 
81
85
  app_ui = ui.page_fluid(
@@ -150,16 +154,17 @@ class PlotApp:
150
154
  # Building inner_ui before entering @module.ui leaves toolbar
151
155
  # inputs unprefixed, while the module server listens for
152
156
  # dataset_N-add_tab / dataset_N-add_table.
157
+ manager = PlotConfigManager(self.original_summary)
153
158
  @module.ui
154
159
  def workspace_ui():
155
160
  inner_ui, _ = _build_summary_ui_server(
156
- data, **self.options, include_theme_picker=False)
161
+ data, **self.options, include_theme_picker=False, config_manager=manager)
157
162
  return inner_ui
158
163
 
159
164
  @module.server
160
165
  def workspace_server(input, output, session):
161
166
  _, inner_server = _build_summary_ui_server(
162
- data, **self.options, include_theme_picker=False)
167
+ data, **self.options, include_theme_picker=False, config_manager=manager)
163
168
  return inner_server(input, output, session)
164
169
 
165
170
  ui_factories[name] = workspace_ui
@@ -0,0 +1,143 @@
1
+ """Plot configuration lifecycle: reactive drafts and explicitly retained presets."""
2
+ from __future__ import annotations
3
+ import copy
4
+ import json
5
+ import uuid
6
+
7
+
8
+ def _json_ready(value):
9
+ """Require portable, plain JSON-compatible configuration values."""
10
+ return json.loads(json.dumps(value, allow_nan=False))
11
+
12
+
13
+ class PlotConfigManager:
14
+ """Keep live tab drafts separate from explicitly retained Summary.plots.
15
+
16
+ All mutations go through methods. When used within Shiny, each tab has
17
+ independent reactive signals for data and appearance changes. Retained
18
+ presets are snapshots and are never modified by draft updates.
19
+ """
20
+
21
+ def __init__(self, summary):
22
+ self.summary = summary
23
+ self.drafts = {}
24
+ self.revision = 0 # Backwards-compatible diagnostic counter, not reactive.
25
+ self._signals = {}
26
+ if getattr(summary, 'plots', None) is None:
27
+ summary.plots = []
28
+ if not isinstance(summary.plots, list):
29
+ raise TypeError('Summary.plots must be a list or None')
30
+
31
+ # Settings that affect filtering, grouping or aggregation. Everything else
32
+ # is treated as presentation state. Panel layout (panel_by) is
33
+ # presentation-only: it must not invalidate filtering or aggregation. Unknown settings remain in the full
34
+ # snapshot and are conservatively classified as appearance-only until
35
+ # explicitly added here.
36
+ DATA_KEYS = frozenset({
37
+ 'plot_type', 'x', 'y', 'numeric_subset_columns',
38
+ 'numeric_subset_bounds', 'subset_chain', 'datetime_start',
39
+ 'datetime_end', 'group_by', 'frequency', 'aggregate',
40
+ 'only_complete', 'flooring_timestamp', 'multi_y_color_by',
41
+ })
42
+
43
+ def _signal(self, tab_id, kind):
44
+ # Created only for a tab that is actually used. Shiny is imported
45
+ # lazily so the manager remains usable outside a Shiny session.
46
+ from shiny import reactive
47
+ signals = self._signals.setdefault(tab_id, {})
48
+ if kind not in signals:
49
+ signals[kind] = reactive.value(0)
50
+ return signals[kind]
51
+
52
+ def watch(self, tab_id, kind='all'):
53
+ """Declare a per-tab reactive dependency (all/data/appearance/visibility)."""
54
+ if kind not in ('all', 'data', 'appearance', 'visibility'):
55
+ raise ValueError('Unknown configuration signal')
56
+ return self._signal(tab_id, kind).get()
57
+
58
+ def update(self, tab_id, config):
59
+ config = _json_ready(config)
60
+ previous = self.drafts.get(tab_id)
61
+ if previous == config:
62
+ return False
63
+ self.drafts[tab_id] = config
64
+ self.revision += 1
65
+ changed = set(config) | set(previous or {})
66
+ changed = {k for k in changed if (previous or {}).get(k) != config.get(k)}
67
+ kinds = {'all'}
68
+ if changed & self.DATA_KEYS:
69
+ kinds.add('data')
70
+ if changed - self.DATA_KEYS:
71
+ kinds.add('appearance')
72
+ if 'visible_elements' in changed:
73
+ kinds.add('visibility')
74
+ # Only notify signals that have subscribers; don't create signals
75
+ # for every tab and category when nobody observes them.
76
+ for kind in kinds:
77
+ signal = self._signals.get(tab_id, {}).get(kind)
78
+ if signal is not None:
79
+ signal.set(signal.get() + 1)
80
+ return True
81
+
82
+ def get(self, tab_id, *, watch=False, kind='all'):
83
+ if watch:
84
+ self.watch(tab_id, kind)
85
+ return copy.deepcopy(self.drafts[tab_id])
86
+
87
+
88
+ def patch(self, tab_id, changes):
89
+ """Apply a partial UI change to the canonical tab configuration.
90
+
91
+ Retain unrelated fields (including settings in inactive sidebar tabs).
92
+ Return False for no-op updates; never mutate a retained preset.
93
+ """
94
+ if not isinstance(changes, dict):
95
+ raise TypeError('Configuration changes must be a dictionary')
96
+ current = self.get(tab_id) if tab_id in self.drafts else {}
97
+ current.update(_json_ready(changes))
98
+ return self.update(tab_id, current)
99
+
100
+ def value(self, tab_id, key, default=None, *, watch=False):
101
+ """Read a setting from one tab without exposing mutable state."""
102
+ if watch:
103
+ self.watch(tab_id)
104
+ return copy.deepcopy(self.drafts.get(tab_id, {}).get(key, default))
105
+
106
+ def discard(self, tab_id):
107
+ if tab_id in self.drafts:
108
+ del self.drafts[tab_id]
109
+ self.revision += 1
110
+ self._signals.pop(tab_id, None)
111
+
112
+ def retain(self, tab_id, name, *, preset_id=None):
113
+ """Persist only this explicitly chosen draft in Summary.plots."""
114
+ if tab_id not in self.drafts:
115
+ raise KeyError(f'No live configuration for {tab_id}')
116
+ name = str(name).strip()
117
+ if not name:
118
+ raise ValueError('Configuration name must not be empty')
119
+ preset_id = preset_id or uuid.uuid4().hex
120
+ item = {'schema_version': 1, 'id': preset_id, 'name': name,
121
+ 'type': 'plot', 'settings': self.get(tab_id)}
122
+ items = copy.deepcopy(self.summary.plots or [])
123
+ for i, old in enumerate(items):
124
+ if old.get('id') == preset_id:
125
+ items[i] = item
126
+ break
127
+ else:
128
+ items.append(item)
129
+ self.summary.plots = items
130
+ self.revision += 1
131
+ return preset_id
132
+
133
+ def saved(self):
134
+ return copy.deepcopy(self.summary.plots or [])
135
+
136
+ def restore(self, preset_id, tab_id):
137
+ item = next((p for p in self.saved() if p.get('id') == preset_id), None)
138
+ if item is None:
139
+ raise KeyError(preset_id)
140
+ if item.get('schema_version') != 1 or item.get('type') != 'plot':
141
+ raise ValueError('Unsupported saved configuration')
142
+ self.update(tab_id, item['settings'])
143
+ return self.get(tab_id)
@@ -0,0 +1,22 @@
1
+ """Pure transformations for canonical plot-filter configuration.
2
+
3
+ No Shiny reactive reads or writes belong here. Both the renderer-facing
4
+ filter state and explicit saved snapshots use this same mapping.
5
+ """
6
+ from __future__ import annotations
7
+ import copy
8
+
9
+
10
+ def filter_config_fields(state):
11
+ """Return independent JSON-compatible draft fields for effective filters.
12
+
13
+ The stage order and selected numerical-column order are preserved.
14
+ Copies prevent later UI mutations from altering a saved snapshot.
15
+ """
16
+ return {
17
+ "subset_chain": copy.deepcopy(state["stages"]),
18
+ "numeric_subset_bounds": copy.deepcopy(state["numeric_bounds"]),
19
+ "numeric_subset_columns": list(state["numeric_bounds"]),
20
+ "datetime_start": state["start"],
21
+ "datetime_end": state["end"],
22
+ }
@@ -170,11 +170,15 @@ def _panel_trace_defaults(spec, panel_by, *, palette="Default"):
170
170
  columns are facets, each panel contains an ungrouped "All observations"
171
171
  trace; when some grouping columns remain, their values label the traces.
172
172
  """
173
+ # Preserve first-seen order while avoiding a linear scan for every
174
+ # repeated trace name across panels (particularly with many facets).
173
175
  names = []
176
+ seen_names = set()
174
177
  for _, part, remaining in _panel_partitions(spec, panel_by):
175
178
  panel_spec = {**spec, "data": part, "groups": remaining}
176
179
  for name in _matplotlib_defaults(panel_spec, palette=palette)["names"]:
177
- if name not in names:
180
+ if name not in seen_names:
181
+ seen_names.add(name)
178
182
  names.append(name)
179
183
  if not names:
180
184
  return _matplotlib_defaults(spec, palette=palette)
@@ -596,7 +600,9 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
596
600
  key = (str(y), str(name) if group_col else None)
597
601
  ax.plot(part[x], part[y], color=(multi_colors[key] if multi_y else colors.get(name, palette_values[0])),
598
602
  linewidth=float(line_width),
599
- linestyle=(multi_dashes[key] if multi_y else _line_style(styles, name)),
603
+ linestyle=(_line_style(styles, name) if not multi_y else
604
+ _line_style(styles, name) if name in styles and len(ys) == 1 else
605
+ multi_dashes[key]),
600
606
  alpha=_bounded_float(alphas, name, 1, 0, 1),
601
607
  label=("_nolegend_" if multi_y else rename.get(name, name)))
602
608
  elif kind in ("box", "bar"):
@@ -44,10 +44,16 @@ def multi_y_styles(variables, groups, palette, mode="group", colors=None, styles
44
44
  # For colour-by-variable, the grouping dimension must control dashes.
45
45
  # Do not let a variable-level style override erase that distinction.
46
46
  default_dash = dash_values[secondary.index(q) % len(dash_values)] if q in secondary else "-"
47
+ # With a single Y variable, newly introduced groups should not
48
+ # inherit a previous ungrouped line style or an implicit dash cycle.
49
+ if len(variables) == 1 and groups and mode == "group":
50
+ default_dash = "-"
47
51
  # The Shiny appearance panel automatically populates each group with
48
52
  # "solid". Those generated defaults must not collapse the
49
53
  # group-specific dash cycle in colour-by-variable mode.
50
54
  override = styles.get(q)
55
+ if override is None and not groups:
56
+ override = styles.get(var, styles.get("All observations"))
51
57
  if mode == "variable" and groups and override in ("solid", "-"):
52
58
  override = None
53
59
  dash_map[(var, group)] = override if override is not None else default_dash
@@ -1,5 +1,6 @@
1
1
  """Shiny application controls, reactive server and launch."""
2
2
  from __future__ import annotations
3
+ from .filter_config import filter_config_fields as _filter_config_fields
3
4
  import threading
4
5
  from datetime import date, datetime, time, timedelta
5
6
  from collections.abc import Sequence
@@ -27,6 +28,7 @@ def _build_summary_ui_server(
27
28
  title: str = "Summary plot",
28
29
  backend: str = "plotly",
29
30
  include_theme_picker: bool = True,
31
+ config_manager=None,
30
32
  ):
31
33
  """Create, but do not start, an interactive Shiny app for a Summary."""
32
34
  from shiny import App, reactive, render, ui, module
@@ -369,7 +371,7 @@ if (!window.__apmColorPickerInstalled) {
369
371
  )
370
372
 
371
373
  @module.server
372
- def plot_tab_server(input, output, session, initial_filters=None):
374
+ def plot_tab_server(input, output, session, initial_filters=None, config_tab_id=None):
373
375
  initial_filters = initial_filters or {}
374
376
  # One stable output per stage: changing a later stage does not
375
377
  # recreate the inputs belonging to earlier stages.
@@ -382,7 +384,7 @@ if (!window.__apmColorPickerInstalled) {
382
384
  if name not in column_map:
383
385
  return ui.div()
384
386
  choices, _ = cached_subset_choices(name)
385
- saved = initial_filters.get("subset_chain", [])
387
+ saved = _live_setting("subset_chain", []) or []
386
388
  selected = (saved[stage].get("values", [])
387
389
  if stage < len(saved) and saved[stage].get("column") == name else [])
388
390
  return ui.input_selectize(f"subset_values_{stage}", f"{name} values",
@@ -403,7 +405,7 @@ if (!window.__apmColorPickerInstalled) {
403
405
  continue
404
406
  # Stable IDs per column preserve entered bounds when selections change.
405
407
  index = numeric_columns.index(name)
406
- bounds = initial_filters.get("numeric_subset_bounds", {}).get(name, {})
408
+ bounds = (_live_setting("numeric_subset_bounds", {}) or {}).get(name, {})
407
409
  controls.append(ui.div(
408
410
  ui.tags.strong(name),
409
411
  ui.input_text(f"numeric_gt_{index}", "Greater than",
@@ -414,28 +416,133 @@ if (!window.__apmColorPickerInstalled) {
414
416
  ))
415
417
  return ui.div(*controls)
416
418
 
417
- @reactive.calc
418
- def filter_state():
419
+
420
+ def _restored_subset_chain():
421
+ """Read mounted controls, falling back to saved values if absent.
422
+
423
+ A hidden/dynamic input may not exist until its UI output mounts.
424
+ An existing empty selection, however, is an intentional user edit.
425
+ """
426
+ saved = _live_setting("subset_chain", []) or []
419
427
  stages = []
420
428
  for i in range(max_subset_stages):
429
+ prior = saved[i] if i < len(saved) else {}
421
430
  try:
422
431
  name = input[f"subset_column_{i}"]()
423
432
  except Exception:
424
- break
433
+ name = prior.get("column", "")
425
434
  if name not in column_map:
426
435
  break
436
+ same_column = name == prior.get("column")
427
437
  try:
428
438
  keys = list(input[f"subset_values_{i}"]() or [])
429
439
  except Exception:
430
- keys = []
440
+ keys = list(prior.get("values", []) or []) if same_column else []
431
441
  try:
432
442
  style = input[f"subset_style_{i}"]() or "all"
433
443
  except Exception:
434
- style = "all"
444
+ style = prior.get("style", "all") if same_column else "all"
435
445
  stages.append({"column": name, "values": keys, "style": style})
436
- return {"stages": stages,
437
- "start": input.datetime_start().strip(),
438
- "end": input.datetime_end().strip()}
446
+ return stages
447
+
448
+ def _restored_numeric_bounds():
449
+ saved = _live_setting("numeric_subset_bounds", {}) or {}
450
+ try:
451
+ names = list(input.numeric_subset_columns() or [])
452
+ except Exception:
453
+ names = list(_live_setting("numeric_subset_columns", []) or [])
454
+ result = {}
455
+ for name in names:
456
+ if name not in numeric_columns:
457
+ continue
458
+ index = numeric_columns.index(name)
459
+ result[name] = {}
460
+ for suffix in ("gt", "lt"):
461
+ try:
462
+ raw = input[f"numeric_{suffix}_{index}"]()
463
+ except Exception:
464
+ raw = saved.get(name, {}).get(suffix, "")
465
+ result[name][suffix] = raw
466
+ return result
467
+
468
+ def _patch_live_config(tab_id, changes):
469
+ """Update a tab draft with older and newer config managers."""
470
+ if hasattr(config_manager, "patch"):
471
+ return config_manager.patch(tab_id, changes)
472
+ current = config_manager.get(tab_id) if tab_id in config_manager.drafts else {}
473
+ current.update(changes)
474
+ return config_manager.update(tab_id, current)
475
+
476
+ def _live_setting(name, default=None):
477
+ """Read the current tab draft, without subscribing to draft writes.
478
+
479
+ Shiny controls remain the source of user events during migration;
480
+ the canonical draft supplies values for unmounted controls.
481
+ """
482
+ if config_manager is not None and config_tab_id is not None:
483
+ with reactive.isolate():
484
+ if config_tab_id in config_manager.drafts:
485
+ return config_manager.value(config_tab_id, name, default) if hasattr(config_manager, "value") else config_manager.get(config_tab_id).get(name, default)
486
+ return initial_filters.get(name, default)
487
+
488
+ @reactive.calc
489
+ def filter_state():
490
+ def text_value(name):
491
+ try:
492
+ return str(getattr(input, name)() or "").strip()
493
+ except Exception:
494
+ return str(_live_setting(name, "") or "").strip()
495
+
496
+ return {"stages": _restored_subset_chain(),
497
+ "numeric_bounds": _restored_numeric_bounds(),
498
+ "start": text_value("datetime_start"),
499
+ "end": text_value("datetime_end")}
500
+
501
+
502
+ def _commit_filter_event():
503
+ """Commit a user filter event without subscribing to configuration writes.
504
+
505
+ Reading all effective controls in an isolated scope prevents an
506
+ unrelated input from becoming a dependency of this event handler.
507
+ Missing dynamic controls retain their canonical draft values.
508
+ """
509
+ if config_manager is None or config_tab_id is None:
510
+ return
511
+ with reactive.isolate():
512
+ changes = _filter_config_fields(filter_state())
513
+ _patch_live_config(config_tab_id, changes)
514
+
515
+ def _register_filter_event(input_name):
516
+ """Register a per-control event, including dynamically mounted inputs."""
517
+ def _event_value():
518
+ try:
519
+ return input[input_name]()
520
+ except Exception:
521
+ return None
522
+
523
+ @reactive.effect
524
+ @reactive.event(_event_value, ignore_init=True)
525
+ def _on_filter_change():
526
+ _commit_filter_event()
527
+
528
+ return _on_filter_change
529
+
530
+ # Retain references for the lifetime of this plot module. Unlike a
531
+ # broad synchronization effect, each handler observes one UI input;
532
+ # no handler subscribes to the draft it writes.
533
+ _filter_event_handlers = []
534
+ for _name in ("numeric_subset_columns", "datetime_start", "datetime_end"):
535
+ _filter_event_handlers.append(_register_filter_event(_name))
536
+ for _stage in range(max_subset_stages):
537
+ for _prefix in ("subset_column", "subset_values", "subset_style"):
538
+ _filter_event_handlers.append(
539
+ _register_filter_event(f"{_prefix}_{_stage}")
540
+ )
541
+ for _index in range(len(numeric_columns)):
542
+ for _suffix in ("gt", "lt"):
543
+ _filter_event_handlers.append(
544
+ _register_filter_event(f"numeric_{_suffix}_{_index}")
545
+ )
439
546
 
440
547
  @reactive.calc
441
548
  def filtered_summary():
@@ -446,7 +553,7 @@ if (!window.__apmColorPickerInstalled) {
446
553
  # Preserve original row adjacency before numerical filtering so a
447
554
  # line cannot bridge observations removed by a numeric bound.
448
555
  source = summary
449
- if input.plot_type() == "line" and AGGREGATES.get(input.aggregate()) is None and list(input.numeric_subset_columns() or []):
556
+ if input.plot_type() == "line" and AGGREGATES.get(input.aggregate()) is None and state["numeric_bounds"]:
450
557
  source = summary.copy()
451
558
  order = source.sort_values((["identifier"] if "identifier" in source.columns else []) + ["datetime"], kind="stable")
452
559
  if "identifier" in order.columns:
@@ -463,20 +570,14 @@ if (!window.__apmColorPickerInstalled) {
463
570
  filter_style=stage["style"],
464
571
  )
465
572
  current = _apply_filters(current, {}, state["start"], state["end"])
466
- for name in list(input.numeric_subset_columns() or []):
467
- if name not in numeric_columns:
468
- continue
469
- index = numeric_columns.index(name)
573
+ for name, bounds in state["numeric_bounds"].items():
470
574
  values = pd.to_numeric(current[column_map[name]], errors="coerce")
471
575
  for suffix, comparison in (("gt", "gt"), ("lt", "lt")):
472
- try:
473
- raw = input[f"numeric_{suffix}_{index}"]()
474
- except Exception:
475
- raw = ""
576
+ raw = bounds.get(suffix, "")
476
577
  try:
477
578
  bound = float(str(raw).strip())
478
579
  except (TypeError, ValueError):
479
- continue # Empty/incomplete inputs do not filter.
580
+ continue
480
581
  if not np.isfinite(bound):
481
582
  continue
482
583
  mask = values.gt(bound) if comparison == "gt" else values.lt(bound)
@@ -489,7 +590,14 @@ if (!window.__apmColorPickerInstalled) {
489
590
  import hashlib
490
591
  _label_fields = ("legend_labels", "panel_titles", "x_tick_labels", "y_tick_labels")
491
592
  _label_keys = {name: reactive.value(()) for name in _label_fields}
492
- _label_values = {name: reactive.value({}) for name in _label_fields}
593
+ from .appearance import _parse_label_map
594
+ def _editor_map(field, *, watch=False):
595
+ # The tab draft owns edited mappings; no parallel reactive dictionary.
596
+ if config_manager is not None and config_tab_id is not None:
597
+ if watch and config_tab_id in config_manager.drafts:
598
+ config_manager.watch(config_tab_id, "appearance")
599
+ return _parse_label_map(_live_setting(field, initial_filters.get(field, "")))
600
+ return _parse_label_map(initial_filters.get(field, ""))
493
601
 
494
602
  def _set_label_keys(field, keys):
495
603
  keys = tuple(dict.fromkeys(str(k) for k in keys if str(k).strip()))
@@ -505,8 +613,9 @@ if (!window.__apmColorPickerInstalled) {
505
613
  # otherwise forms plot -> legend keys -> plot feedback cycles.
506
614
  with reactive.isolate():
507
615
  keys = _label_keys[field].get()
508
- values = _label_values[field].get()
509
- return "; ".join(f"{key}={values.get(key, key)}" for key in keys
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
510
619
  if values.get(key, key) != key)
511
620
 
512
621
  for _field in _label_fields:
@@ -516,7 +625,7 @@ if (!window.__apmColorPickerInstalled) {
516
625
  def _editor():
517
626
  keys = _label_keys[field].get()
518
627
  with reactive.isolate():
519
- values = _label_values[field].get()
628
+ values = _editor_map(field, watch=True)
520
629
  headings = {
521
630
  "legend_labels": "Legend labels",
522
631
  "panel_titles": "Panel titles",
@@ -546,7 +655,7 @@ if (!window.__apmColorPickerInstalled) {
546
655
  # Read current mappings without subscribing this watcher to
547
656
  # its own writes: otherwise each keystroke can retrigger it.
548
657
  with reactive.isolate():
549
- updated = dict(_label_values[field].get())
658
+ updated = dict(_editor_map(field))
550
659
  changed = False
551
660
  for key in keys:
552
661
  ident = f"edit_{field}_{hashlib.sha1(key.encode()).hexdigest()[:16]}"
@@ -555,7 +664,10 @@ if (!window.__apmColorPickerInstalled) {
555
664
  updated[key] = value
556
665
  changed = True
557
666
  if changed:
558
- _label_values[field].set(updated)
667
+ if config_manager is not None and config_tab_id is not None:
668
+ _patch_live_config(config_tab_id, {
669
+ field: "; ".join(f"{k}={v}" for k, v in updated.items() if v != k)
670
+ })
559
671
  _make_watcher(_field)
560
672
 
561
673
  # Colour keys describe the actual colour encoding, not the number
@@ -563,11 +675,12 @@ if (!window.__apmColorPickerInstalled) {
563
675
  # determining the active within-panel grouping.
564
676
  from .color_editor import active_color_keys, color_defaults, valid_hex_color
565
677
  _color_keys = reactive.value(())
566
- _color_values = reactive.value({})
678
+ def _color_map(*, watch=False):
679
+ return _editor_map("trace_colors", watch=watch)
567
680
 
568
681
  def _color_string():
569
682
  keys = _color_keys.get()
570
- values = _color_values.get()
683
+ values = _color_map(watch=True)
571
684
  return "; ".join(f"{key}={values[key]}" for key in keys if key in values)
572
685
 
573
686
  # Each rendered colour key has a stable swatch button. The popup
@@ -582,7 +695,7 @@ if (!window.__apmColorPickerInstalled) {
582
695
  @render.ui
583
696
  def trace_colors_editor():
584
697
  keys = _color_keys.get()
585
- values = _color_values.get()
698
+ values = _color_map(watch=True)
586
699
  with reactive.isolate():
587
700
  palette = _optional_input("palette", "Default")
588
701
  spec = prepared_plot()
@@ -619,7 +732,7 @@ if (!window.__apmColorPickerInstalled) {
619
732
  if clicked is None:
620
733
  return
621
734
  with reactive.isolate():
622
- values = _color_values.get()
735
+ values = _color_map()
623
736
  defaults = color_defaults(prepared_plot(), (clicked,),
624
737
  _optional_input("palette", "Default"))
625
738
  current = values.get(clicked, defaults.get(clicked, "#636efa"))
@@ -650,9 +763,12 @@ if (!window.__apmColorPickerInstalled) {
650
763
  value = str(_optional_input("picker_hex", "")).strip()
651
764
  if key is not None and valid_hex_color(value):
652
765
  with reactive.isolate():
653
- values = dict(_color_values.get())
766
+ values = dict(_color_map())
654
767
  values[key] = value.upper()
655
- _color_values.set(values)
768
+ if config_manager is not None and config_tab_id is not None:
769
+ _patch_live_config(config_tab_id, {
770
+ "trace_colors": "; ".join(f"{k}={v}" for k, v in values.items())
771
+ })
656
772
  ui.modal_remove()
657
773
  else:
658
774
  ui.notification_show("Enter a valid six-digit hex colour (#RRGGBB).",
@@ -1082,34 +1198,62 @@ if (!window.__apmColorPickerInstalled) {
1082
1198
  def available_elements():
1083
1199
  return element_keys(unfiltered_plot())
1084
1200
 
1085
- # Visibility is owned by the plot, not by the dynamically mounted
1086
- # checkbox widget. Rebuilding the widget must not feed back into
1087
- # its own render dependency (which previously caused a render loop).
1201
+ # The live configuration is authoritative for component visibility.
1202
+ # Keep only the previous *available keys* locally: this is derived
1203
+ # renderer metadata, not another copy of the user's selection.
1088
1204
  inherited_visible = initial_filters.get("visible_elements")
1089
- visible_element_selection = reactive.Value(
1090
- tuple(inherited_visible) if inherited_visible is not None else None
1091
- )
1092
1205
  element_key_signature = reactive.Value(None)
1093
1206
 
1207
+ def configured_visibility():
1208
+ """Read canonical visibility; None means all components visible."""
1209
+ if config_manager is not None and config_tab_id is not None:
1210
+ if config_tab_id in config_manager.drafts:
1211
+ return config_manager.value(config_tab_id, "visible_elements", inherited_visible)
1212
+ return inherited_visible
1213
+
1094
1214
  @reactive.effect
1095
- def reset_visibility_for_new_elements():
1215
+ def reconcile_visible_elements():
1096
1216
  keys = tuple(available_elements())
1097
1217
  with reactive.isolate():
1098
1218
  previous = element_key_signature.get()
1099
- if previous != keys:
1100
- if previous is not None:
1101
- visible_element_selection.set(tuple(default_visible_keys(unfiltered_plot())))
1102
- element_key_signature.set(keys)
1219
+ saved = configured_visibility()
1220
+ # Dynamic controls can briefly expose no components on restore.
1221
+ # Never overwrite saved visibility during that transient phase.
1222
+ if not keys:
1223
+ return
1224
+ if previous == keys:
1225
+ return
1226
+ if saved is None:
1227
+ selected = keys
1228
+ else:
1229
+ selected = tuple(k for k in keys if k in saved)
1230
+ if previous is None and not selected:
1231
+ # Old snapshots may contain stale/empty component IDs.
1232
+ selected = keys
1233
+ elif previous is not None:
1234
+ old_keys = set(previous)
1235
+ selected = tuple(k for k in keys if k in selected or k not in old_keys)
1236
+ element_key_signature.set(keys)
1237
+ if config_manager is not None and config_tab_id is not None:
1238
+ _patch_live_config(config_tab_id, {"visible_elements": list(selected)})
1103
1239
 
1104
1240
  @render.ui
1105
1241
  def element_visibility_ui():
1106
1242
  keys = tuple(available_elements())
1107
1243
  with reactive.isolate():
1108
- current = visible_element_selection.get()
1109
- chosen = (tuple(default_visible_keys(unfiltered_plot())) if current is None else
1110
- tuple(k for k in current if k in keys))
1244
+ saved = configured_visibility()
1245
+ chosen = keys if saved is None else tuple(k for k in keys if k in saved)
1111
1246
  if not keys:
1112
- return ui.help_text("No plotted elements available")
1247
+ # An empty filtered dataset is temporary. Keep the last
1248
+ # component controls mounted instead of replacing them
1249
+ # with help text: unmounting sends an empty checkbox value
1250
+ # which can later hide every line when data returns.
1251
+ with reactive.isolate():
1252
+ previous = element_key_signature.get() or ()
1253
+ if not previous:
1254
+ return ui.help_text("No plotted elements available")
1255
+ keys = previous
1256
+ chosen = keys if saved is None else tuple(k for k in keys if k in saved)
1113
1257
  return ui.input_checkbox_group(
1114
1258
  "visible_elements", "Visible plotted elements",
1115
1259
  choices={key: key for key in keys}, selected=list(chosen),
@@ -1122,18 +1266,26 @@ if (!window.__apmColorPickerInstalled) {
1122
1266
  if chosen is None:
1123
1267
  return
1124
1268
  keys = tuple(available_elements())
1125
- chosen = tuple(k for k in chosen if k in keys)
1126
1269
  with reactive.isolate():
1127
- current = visible_element_selection.get()
1128
- effective = tuple(default_visible_keys(unfiltered_plot())) if current is None else current
1129
- if chosen != effective:
1130
- visible_element_selection.set(chosen)
1270
+ signature = element_key_signature.get()
1271
+ saved = configured_visibility()
1272
+ # Ignore stale events from an unmounted/rebuilt checkbox group.
1273
+ if not keys or signature != keys:
1274
+ return
1275
+ chosen = tuple(k for k in chosen if k in keys)
1276
+ effective = keys if saved is None else tuple(k for k in keys if k in saved)
1277
+ if chosen != effective and config_manager is not None and config_tab_id is not None:
1278
+ _patch_live_config(config_tab_id, {"visible_elements": list(chosen)})
1131
1279
 
1132
1280
  @reactive.calc
1133
1281
  def prepared_plot():
1134
1282
  raw = unfiltered_plot()
1135
- keys = tuple(available_elements())
1136
- chosen = visible_element_selection.get()
1283
+ # Visibility changes alter the plot specification. Other appearance
1284
+ # edits (labels, colours, panel spacing) must not invalidate it:
1285
+ # renderers can reuse the already prepared data/specification.
1286
+ if config_manager is not None and config_tab_id is not None:
1287
+ config_manager.watch(config_tab_id, "visibility")
1288
+ chosen = configured_visibility()
1137
1289
  if chosen is None:
1138
1290
  chosen = tuple(default_visible_keys(raw))
1139
1291
  return filter_elements(raw, chosen)
@@ -1212,6 +1364,8 @@ if (!window.__apmColorPickerInstalled) {
1212
1364
  # Populate appearance controls only after a valid plot has been
1213
1365
  # prepared. The effect depends on the plot specification, not on
1214
1366
  # appearance inputs, so editing a label or colour cannot reset it.
1367
+ _last_line_style_keys = [None]
1368
+
1215
1369
  @reactive.effect
1216
1370
  @reactive.event(prepared_plot, lambda: _optional_input("multi_panel", False) if input.group_by() else False,
1217
1371
  lambda: tuple(_optional_input("panel_by", []) or []) if input.group_by() and len(input.group_by()) > 1 else (),
@@ -1237,9 +1391,22 @@ if (!window.__apmColorPickerInstalled) {
1237
1391
  )
1238
1392
  names = trace_defaults["names"]
1239
1393
  defaults["colors"] = trace_defaults["colors"]
1240
- ui.update_text("title", value="Summary plot")
1241
- ui.update_text("x_axis_label", value=defaults["x_label"])
1242
- ui.update_text_area("y_axis_label", value=defaults["y_label"])
1394
+ # On a restored tab, the saved labels/title are authoritative.
1395
+ # The preparation effect can run after the controls mount and
1396
+ # otherwise overwrite the saved values with generated defaults.
1397
+ # Resume automatic labels when the underlying data selection changes.
1398
+ restored_data_keys = ("plot_type", "x", "y", "group_by", "frequency", "aggregate")
1399
+ same_restored_data = bool(initial_filters) and all(
1400
+ (list(_optional_input(k, []) or []) if k in ("x", "y", "group_by") else _optional_input(k, None))
1401
+ == (list(initial_filters.get(k, []) or []) if k in ("x", "y", "group_by") else initial_filters.get(k))
1402
+ for k in restored_data_keys if k in initial_filters
1403
+ )
1404
+ if not same_restored_data or "title" not in initial_filters:
1405
+ ui.update_text("title", value="Summary plot")
1406
+ if not same_restored_data or "x_axis_label" not in initial_filters:
1407
+ ui.update_text("x_axis_label", value=defaults["x_label"])
1408
+ if not same_restored_data or "y_axis_label" not in initial_filters:
1409
+ ui.update_text_area("y_axis_label", value=defaults["y_label"])
1243
1410
  limits = _axis_bounds_from_spec(
1244
1411
  spec,
1245
1412
  bins=int(input.bins()) if spec["kind"] == "histogram" else 30,
@@ -1290,8 +1457,26 @@ if (!window.__apmColorPickerInstalled) {
1290
1457
  # active plot and never reset by appearance defaults.
1291
1458
  kind = spec["kind"]
1292
1459
  if kind == "line":
1293
- ui.update_text_area("line_alpha", value="\n".join(f"{name}=1" for name in names))
1294
- ui.update_text_area("line_style", value="\n".join(f"{name}=solid" for name in names))
1460
+ # Only reconcile the editors when their trace identities change.
1461
+ # Plot preparation may rerun for unrelated reasons (filters,
1462
+ # panels, etc.); resetting these inputs on each run races with
1463
+ # the user's edits and makes repeated style changes unreliable.
1464
+ current_keys = tuple(names)
1465
+ previous_keys = _last_line_style_keys[0]
1466
+ _last_line_style_keys[0] = current_keys
1467
+ if previous_keys != current_keys:
1468
+ for field, default in (("line_alpha", "1"), ("line_style", "solid")):
1469
+ if previous_keys is None and same_restored_data and field in initial_filters:
1470
+ # The saved configuration is already mounted.
1471
+ continue
1472
+ with reactive.isolate():
1473
+ existing = _parse_label_map(_optional_input(field, ""))
1474
+ # Preserve edits for identities that survive a data
1475
+ # change; initialize only newly discovered traces.
1476
+ updated = "\n".join(
1477
+ f"{name}={existing.get(name, default)}" for name in names
1478
+ )
1479
+ ui.update_text_area(field, value=updated)
1295
1480
  elif kind == "histogram":
1296
1481
  ui.update_text_area("hist_alpha", value="\n".join(f"{name}=0.65" for name in names))
1297
1482
  elif kind == "scatter":
@@ -1481,8 +1666,11 @@ if (!window.__apmColorPickerInstalled) {
1481
1666
 
1482
1667
  def _capture_legend(fig, is_plotly=False):
1483
1668
  from .appearance import _parse_label_map
1669
+ # Use the full saved mapping during initial legend discovery.
1670
+ # _label_string() deliberately filters by discovered keys, which
1671
+ # are still empty when a restored tab first renders.
1484
1672
  with reactive.isolate():
1485
- mapping = _parse_label_map(_label_string("legend_labels"))
1673
+ mapping = dict(_editor_map("legend_labels"))
1486
1674
  # Resolve displayed labels against stable, original keys. A
1487
1675
  # reverse dictionary is unsafe: two different keys may have
1488
1676
  # the same edited label, and mapping order then makes keys
@@ -1678,6 +1866,11 @@ if (!window.__apmColorPickerInstalled) {
1678
1866
  def matplotlib_options():
1679
1867
  # Shared with the export so that all appearance options,
1680
1868
  # including per-panel overrides, are reproduced exactly.
1869
+ # Read grouping/panel controls once per reactive evaluation.
1870
+ # These options affect rendering, not selected_data().
1871
+ active_groups = list(input.group_by() or [])
1872
+ multi_panel = bool(_optional_input("multi_panel", False)) if active_groups else False
1873
+ panel_groups = (list(_optional_input("panel_by", []) or []) or active_groups) if len(active_groups) > 1 else active_groups
1681
1874
  return dict(title=_optional_input("title", ""),
1682
1875
  x_label=_optional_input("x_axis_label", ""), y_label=_optional_input("y_axis_label", ""),
1683
1876
  legend_labels=_label_string("legend_labels"), trace_colors=_color_string(),
@@ -1694,24 +1887,23 @@ if (!window.__apmColorPickerInstalled) {
1694
1887
  panel_font_size=_optional_input("panel_font_size", 14), plot_dpi=int(_optional_input("plot_dpi", 100) or 100), hist_alpha=_optional_input("hist_alpha", ""),
1695
1888
  box_showfliers=_optional_input("box_showfliers", True), box_orientation=_optional_input("box_orientation", "vertical"),
1696
1889
  bar_box_width=float(_optional_input("bar_box_width", 1.0)),
1697
- line_width=(_optional_input("line_width", 2) if input.plot_type() == "line" else 2), line_alpha=_optional_input("line_alpha", ""),
1698
- line_style=_optional_input("line_style", ""), scatter_size=_optional_input("scatter_size", ""),
1699
- scatter_marker=_optional_input("scatter_marker", ""), scatter_alpha=_optional_input("scatter_alpha", ""),
1890
+ line_width=(_optional_input("line_width", 2) if input.plot_type() == "line" else 2),
1891
+ line_alpha=(_optional_input("line_alpha", "") if input.plot_type() == "line" else ""),
1892
+ line_style=(_optional_input("line_style", "") if input.plot_type() == "line" else ""),
1893
+ scatter_size=(_optional_input("scatter_size", "") if input.plot_type() == "scatter" else ""),
1894
+ scatter_marker=(_optional_input("scatter_marker", "") if input.plot_type() == "scatter" else ""),
1895
+ scatter_alpha=(_optional_input("scatter_alpha", "") if input.plot_type() == "scatter" else ""),
1700
1896
  bins=int(_optional_input("bins", 30)) if input.plot_type() == "histogram" else 30,
1701
1897
  density=_optional_input("hist_density", "False") == "True" if input.plot_type() == "histogram" else False,
1702
1898
  stacked=_optional_input("hist_stacked", "False") == "True" if input.plot_type() == "histogram" else False,
1703
1899
  histtype=_optional_input("hist_type", "bar") if input.plot_type() == "histogram" else "bar",
1704
1900
  cumulative=int(_optional_input("hist_cumulative", "0")) if input.plot_type() == "histogram" else 0,
1705
- panel_by=(
1706
- ((list(_optional_input("panel_by", []) or []) or list(input.group_by() or [])) if len(list(input.group_by() or [])) > 1
1707
- else list(input.group_by() or []))
1708
- if list(input.group_by() or []) and bool(_optional_input("multi_panel", False)) else None
1709
- ),
1901
+ panel_by=(panel_groups if multi_panel else None),
1710
1902
  independent_panel_limits=(bool(_optional_input("independent_panel_limits", True))
1711
- if input.group_by() and _optional_input("multi_panel", False) else True),
1712
- panel_titles=(_label_string("panel_titles") if input.group_by() and _optional_input("multi_panel", False) else ""),
1713
- panel_hspace=float(_optional_input("panel_hspace", 0.90) or 0),
1714
- panel_wspace=float(_optional_input("panel_wspace", 0.38) or 0),
1903
+ if multi_panel else True),
1904
+ panel_titles=(_label_string("panel_titles") if multi_panel else ""),
1905
+ panel_hspace=(float(_optional_input("panel_hspace", 0.90) or 0) if multi_panel else 0.90),
1906
+ panel_wspace=(float(_optional_input("panel_wspace", 0.38) or 0) if multi_panel else 0.38),
1715
1907
  )
1716
1908
 
1717
1909
  @render.download(filename="plot_export.zip", media_type="application/zip")
@@ -1750,7 +1942,13 @@ if (!window.__apmColorPickerInstalled) {
1750
1942
  "hist_cumulative", "hist_alpha", "box_showfliers", "box_orientation", "bar_box_width",
1751
1943
  "multi_panel", "independent_panel_limits", "panel_by", "panel_titles",
1752
1944
  "panel_font_size", "panel_hspace", "panel_wspace", "visible_elements")
1753
- result = dict(initial_filters)
1945
+ # The live draft is the canonical state. UI controls contribute
1946
+ # only the fields currently mounted, preserving settings from
1947
+ # inactive/dynamic panels instead of silently dropping them.
1948
+ with reactive.isolate():
1949
+ result = (config_manager.get(config_tab_id)
1950
+ if config_manager is not None and config_tab_id in config_manager.drafts
1951
+ else dict(initial_filters))
1754
1952
  for key in keys:
1755
1953
  try:
1756
1954
  value = (_label_string(key) if key in _label_fields else getattr(input, key)())
@@ -1759,39 +1957,30 @@ if (!window.__apmColorPickerInstalled) {
1759
1957
  except Exception:
1760
1958
  # Inputs in inactive tabs may not have been initialized yet.
1761
1959
  pass
1762
- chain = []
1763
- for i in range(max_subset_stages):
1764
- try:
1765
- name = input[f"subset_column_{i}"]()
1766
- except Exception:
1767
- break
1768
- if name not in column_map:
1769
- break
1770
- try:
1771
- values = list(input[f"subset_values_{i}"]() or [])
1772
- except Exception:
1773
- values = []
1774
- try:
1775
- style = input[f"subset_style_{i}"]() or "all"
1776
- except Exception:
1777
- style = "all"
1778
- chain.append({"column": name, "values": values, "style": style})
1779
- result["subset_chain"] = chain
1780
- numeric_bounds = {}
1781
- for name in result.get("numeric_subset_columns", []) or []:
1782
- if name not in numeric_columns:
1783
- continue
1784
- index = numeric_columns.index(name)
1785
- numeric_bounds[name] = {}
1786
- for suffix in ("gt", "lt"):
1787
- try:
1788
- numeric_bounds[name][suffix] = input[f"numeric_{suffix}_{index}"]()
1789
- except Exception:
1790
- numeric_bounds[name][suffix] = initial_filters.get("numeric_subset_bounds", {}).get(name, {}).get(suffix, "")
1791
- result["numeric_subset_bounds"] = numeric_bounds
1960
+ # Snapshot the same effective filter state used by the renderer,
1961
+ # including saved values for controls not yet mounted.
1792
1962
  with reactive.isolate():
1793
- chosen = visible_element_selection.get()
1794
- result["visible_elements"] = list(default_visible_keys(unfiltered_plot()) if chosen is None else chosen)
1963
+ effective_filters = filter_state()
1964
+ result.update(_filter_config_fields(effective_filters))
1965
+ # These editors are reactive dictionaries, not Shiny inputs.
1966
+ # Persist their full mappings rather than relying on input.*.
1967
+ for field in _label_fields:
1968
+ with reactive.isolate():
1969
+ values = dict(_editor_map(field))
1970
+ result[field] = "; ".join(f"{key}={value}" for key, value in values.items()
1971
+ if value != key)
1972
+ with reactive.isolate():
1973
+ colors = dict(_color_map())
1974
+ chosen = configured_visibility()
1975
+ visible_keys = tuple(available_elements())
1976
+ result["trace_colors"] = "; ".join(f"{key}={value}" for key, value in colors.items())
1977
+ # No available components must not erase visibility in a
1978
+ # retained configuration (e.g. threshold above maximum).
1979
+ if not visible_keys:
1980
+ result["visible_elements"] = None if chosen is None else list(chosen)
1981
+ else:
1982
+ result["visible_elements"] = list(visible_keys if chosen is None else
1983
+ (k for k in chosen if k in visible_keys))
1795
1984
  return result
1796
1985
 
1797
1986
  # Expose the same dataframe that feeds plot preparation/export.
@@ -2139,6 +2328,8 @@ if (!window.__apmColorPickerInstalled) {
2139
2328
  ui.input_action_button("add_tab", "+ New plot", class_="btn-primary"),
2140
2329
  ui.input_action_button("add_table", "+ New table", class_="btn-primary"),
2141
2330
  ui.input_action_button("close_tab", "Close selected tab", class_="btn-outline-danger"),
2331
+ ui.input_action_button("retain_plot", "Save selected plot", class_="btn-outline-primary"),
2332
+ ui.input_action_button("restore_plot", "Load saved plot", class_="btn-outline-primary"),
2142
2333
  class_="summary-toolbar-actions",
2143
2334
  ),
2144
2335
  # Exactly one app-wide theme picker, outside dynamically added
@@ -2162,6 +2353,7 @@ if (!window.__apmColorPickerInstalled) {
2162
2353
  )
2163
2354
 
2164
2355
  def server(input, output, session):
2356
+ nonlocal config_manager
2165
2357
  # Register shinyswatch at the application root (not inside plot modules).
2166
2358
  # Its picker dynamically switches the app's Bootstrap theme without
2167
2359
  # touching plot-specific options or the plot cloning logic.
@@ -2172,6 +2364,84 @@ if (!window.__apmColorPickerInstalled) {
2172
2364
  tab_filters = {}
2173
2365
  table_frames = {}
2174
2366
  next_table = reactive.value(0)
2367
+ from ..plot_configs import PlotConfigManager
2368
+ manager = config_manager or PlotConfigManager(data)
2369
+ # Use the same manager in each plot module and in the toolbar actions.
2370
+ config_manager = manager
2371
+ saved_revision = reactive.value(0)
2372
+ # Drafts track live input state; only Save selected plot changes Summary.plots.
2373
+ def register_draft(tab_id, snapshot):
2374
+ @reactive.effect
2375
+ def sync_draft():
2376
+ try:
2377
+ state = snapshot()
2378
+ manager.update(tab_id, state)
2379
+ except (TypeError, ValueError):
2380
+ # A transient invalid input must not overwrite the last good draft.
2381
+ pass
2382
+
2383
+ @reactive.effect
2384
+ @reactive.event(input.retain_plot)
2385
+ def retain_selected():
2386
+ selected = input.plot_tabs()
2387
+ if selected not in tab_filters:
2388
+ ui.notification_show("Select a plot tab to save", type="warning")
2389
+ return
2390
+ ui.modal_show(ui.modal(
2391
+ ui.input_text("retained_plot_name", "Configuration name", value=f"Saved {selected}"),
2392
+ title="Save plot configuration",
2393
+ footer=ui.div(ui.modal_button("Cancel"),
2394
+ ui.input_action_button("confirm_retain_plot", "Save", class_="btn-primary")),
2395
+ ))
2396
+
2397
+ @reactive.effect
2398
+ @reactive.event(input.confirm_retain_plot)
2399
+ def confirm_retain():
2400
+ selected = input.plot_tabs()
2401
+ try:
2402
+ with reactive.isolate():
2403
+ manager.update(selected, tab_filters[selected]())
2404
+ manager.retain(selected, input.retained_plot_name())
2405
+ saved_revision.set(saved_revision.get() + 1)
2406
+ ui.modal_remove()
2407
+ ui.notification_show("Plot configuration retained in Summary.plots", type="message")
2408
+ except Exception as exc:
2409
+ ui.notification_show(f"Could not save configuration: {exc}", type="error")
2410
+
2411
+ @reactive.effect
2412
+ @reactive.event(input.restore_plot)
2413
+ def choose_saved():
2414
+ saved_revision.get()
2415
+ presets = [p for p in manager.saved() if p.get("type") == "plot"]
2416
+ if not presets:
2417
+ ui.notification_show("No retained plot configurations", type="warning")
2418
+ return
2419
+ ui.modal_show(ui.modal(
2420
+ ui.input_select("retained_plot_choice", "Saved plot",
2421
+ choices={p["id"]: p.get("name", p["id"]) for p in presets}),
2422
+ title="Load saved plot",
2423
+ footer=ui.div(ui.modal_button("Cancel"),
2424
+ ui.input_action_button("confirm_restore_plot", "Load", class_="btn-primary")),
2425
+ ))
2426
+
2427
+ @reactive.effect
2428
+ @reactive.event(input.confirm_restore_plot)
2429
+ def restore_selected():
2430
+ try:
2431
+ with reactive.isolate():
2432
+ preset_id = input.retained_plot_choice()
2433
+ number = next_tab.get() + 1
2434
+ tab_id = f"plot_{number}"
2435
+ inherited = manager.restore(preset_id, tab_id)
2436
+ panel = ui.nav_panel(f"Plot {number}", plot_tab_ui(tab_id, inherited), value=tab_id)
2437
+ tab_filters[tab_id] = plot_tab_server(tab_id, inherited, config_tab_id=tab_id)
2438
+ register_draft(tab_id, tab_filters[tab_id])
2439
+ next_tab.set(number)
2440
+ active_tabs.set((*active_tabs.get(), tab_id))
2441
+ ui.insert_nav_panel("plot_tabs", panel, select=True)
2442
+ ui.modal_remove()
2443
+ except Exception as exc:
2444
+ ui.notification_show(f"Could not restore plot: {exc}", type="error")
2175
2445
 
2176
2446
  @reactive.effect
2177
2447
  @reactive.event(input.add_tab)
@@ -2191,7 +2461,8 @@ if (!window.__apmColorPickerInstalled) {
2191
2461
  # Build the new UI before registering the server module. This also
2192
2462
  # avoids leaving a half-registered module when UI creation fails.
2193
2463
  new_panel = ui.nav_panel(f"Plot {number}", plot_tab_ui(tab_id, inherited), value=tab_id)
2194
- tab_filters[tab_id] = plot_tab_server(tab_id, inherited)
2464
+ tab_filters[tab_id] = plot_tab_server(tab_id, inherited, config_tab_id=tab_id)
2465
+ register_draft(tab_id, tab_filters[tab_id])
2195
2466
  active_tabs.set((*active_tabs.get(), tab_id))
2196
2467
  ui.insert_nav_panel("plot_tabs", new_panel, select=True)
2197
2468
 
@@ -2235,6 +2506,7 @@ if (!window.__apmColorPickerInstalled) {
2235
2506
  ui.remove_nav_panel("plot_tabs", target=selected)
2236
2507
  table_frames.pop(selected, None)
2237
2508
  tab_filters.pop(selected, None)
2509
+ manager.discard(selected)
2238
2510
  active_tabs.set(tuple(t for t in tabs if t != selected))
2239
2511
  # Shiny does not provide a public API to unregister an output module.
2240
2512
  # The removed tab cannot be selected again and tab IDs are never reused.
File without changes
File without changes
File without changes