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.
- {apmtools-0.2.51 → apmtools-0.2.52}/PKG-INFO +1 -1
- {apmtools-0.2.51 → apmtools-0.2.52}/pyproject.toml +1 -1
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/classes.py +3 -3
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/plot_app.py +7 -2
- apmtools-0.2.52/src/apmtools/plot_configs.py +143 -0
- apmtools-0.2.52/src/apmtools/shiny/filter_config.py +22 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/matplotlib_renderer.py +8 -2
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/multi_y.py +6 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/ui.py +379 -107
- {apmtools-0.2.51 → apmtools-0.2.52}/.gitignore +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/LICENSE +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/README.md +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/appearance.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/color_editor.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/data.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/multi_axis.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/plotly_renderer.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.52}/src/apmtools/shiny/summary.py +0 -0
- {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.
|
|
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>
|
|
@@ -1011,7 +1011,7 @@ class Group(pd.DataFrame):
|
|
|
1011
1011
|
|
|
1012
1012
|
class Summary(pd.DataFrame):
|
|
1013
1013
|
|
|
1014
|
-
_metadata = ['metadata', '
|
|
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 == "
|
|
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, "
|
|
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
|
|
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=(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
418
|
-
def
|
|
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
|
-
|
|
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
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
509
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
766
|
+
values = dict(_color_map())
|
|
654
767
|
values[key] = value.upper()
|
|
655
|
-
|
|
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
|
-
#
|
|
1086
|
-
#
|
|
1087
|
-
#
|
|
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
|
|
1215
|
+
def reconcile_visible_elements():
|
|
1096
1216
|
keys = tuple(available_elements())
|
|
1097
1217
|
with reactive.isolate():
|
|
1098
1218
|
previous = element_key_signature.get()
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
-
|
|
1109
|
-
chosen =
|
|
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
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
-
|
|
1294
|
-
|
|
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 =
|
|
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),
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
|
1712
|
-
panel_titles=(_label_string("panel_titles") if
|
|
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
|
-
|
|
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
|
-
|
|
1763
|
-
for
|
|
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
|
-
|
|
1794
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|