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