apmtools 0.2.51__tar.gz → 0.2.53__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.53}/PKG-INFO +1 -1
- {apmtools-0.2.51 → apmtools-0.2.53}/pyproject.toml +1 -1
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/classes.py +3 -3
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/plot_app.py +7 -2
- apmtools-0.2.53/src/apmtools/plot_configs.py +143 -0
- apmtools-0.2.53/src/apmtools/shiny/filter_config.py +22 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/matplotlib_renderer.py +64 -16
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/multi_y.py +6 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/ui.py +452 -115
- {apmtools-0.2.51 → apmtools-0.2.53}/.gitignore +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/LICENSE +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/README.md +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/appearance.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/color_editor.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/data.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/multi_axis.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/plotly_renderer.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/summary.py +0 -0
- {apmtools-0.2.51 → apmtools-0.2.53}/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.53
|
|
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
|
+
}
|
|
@@ -47,6 +47,17 @@ def _axis_bounds_from_spec(spec, *, bins=30, density=False, stacked=False, cumul
|
|
|
47
47
|
if "error" in spec:
|
|
48
48
|
return {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
|
|
49
49
|
df, kind, x, ys = spec["data"], spec["kind"], spec["x"], spec["ys"]
|
|
50
|
+
# A long-form Y grouping must render its value column, not a source
|
|
51
|
+
# column removed by melt(). Normalize the local spec before faceting.
|
|
52
|
+
if kind == "scatter" and "__axis_y_value" in df.columns and any(y not in df.columns for y in ys):
|
|
53
|
+
ys = ["__axis_y_value"]
|
|
54
|
+
spec = {**spec, "ys": ys}
|
|
55
|
+
|
|
56
|
+
# Reactive selection changes can briefly expose a specification whose
|
|
57
|
+
# original Y names are no longer present after a melt/reshape.
|
|
58
|
+
ys = [y for y in ys if y in df.columns]
|
|
59
|
+
if x not in df.columns or (kind != "histogram" and not ys):
|
|
60
|
+
return {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
|
|
50
61
|
if df.empty:
|
|
51
62
|
return {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
|
|
52
63
|
bounds = {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
|
|
@@ -170,11 +181,15 @@ def _panel_trace_defaults(spec, panel_by, *, palette="Default"):
|
|
|
170
181
|
columns are facets, each panel contains an ungrouped "All observations"
|
|
171
182
|
trace; when some grouping columns remain, their values label the traces.
|
|
172
183
|
"""
|
|
184
|
+
# Preserve first-seen order while avoiding a linear scan for every
|
|
185
|
+
# repeated trace name across panels (particularly with many facets).
|
|
173
186
|
names = []
|
|
187
|
+
seen_names = set()
|
|
174
188
|
for _, part, remaining in _panel_partitions(spec, panel_by):
|
|
175
189
|
panel_spec = {**spec, "data": part, "groups": remaining}
|
|
176
190
|
for name in _matplotlib_defaults(panel_spec, palette=palette)["names"]:
|
|
177
|
-
if name not in
|
|
191
|
+
if name not in seen_names:
|
|
192
|
+
seen_names.add(name)
|
|
178
193
|
names.append(name)
|
|
179
194
|
if not names:
|
|
180
195
|
return _matplotlib_defaults(spec, palette=palette)
|
|
@@ -360,6 +375,12 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
360
375
|
return fig
|
|
361
376
|
|
|
362
377
|
df, kind, x, ys = spec["data"], spec["kind"], spec["x"], spec["ys"]
|
|
378
|
+
# A long-form Y grouping must render its value column, not a source
|
|
379
|
+
# column removed by melt(). Normalize the local spec before faceting.
|
|
380
|
+
if kind == "scatter" and "__axis_y_value" in df.columns and any(y not in df.columns for y in ys):
|
|
381
|
+
ys = ["__axis_y_value"]
|
|
382
|
+
spec = {**spec, "ys": ys}
|
|
383
|
+
|
|
363
384
|
# Facet only the native Matplotlib renderer. Each panel receives the
|
|
364
385
|
# same filtered/aggregated data subset and the same appearance settings.
|
|
365
386
|
if panel_by and _axes is None:
|
|
@@ -400,19 +421,23 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
400
421
|
stacked=stacked, histtype=histtype, cumulative=cumulative, _axes=panel_ax,
|
|
401
422
|
x_major_ticks=x_major_ticks, y_major_ticks=y_major_ticks,
|
|
402
423
|
x_minor_ticks=x_minor_ticks, y_minor_ticks=y_minor_ticks)
|
|
403
|
-
|
|
404
|
-
|
|
424
|
+
# Resolve the axes once; reuse the same ordered panel collection for
|
|
425
|
+
# legend layout rather than repeatedly materializing axes.flat.
|
|
426
|
+
flat_axes = axes.flat
|
|
427
|
+
active_axes = [flat_axes[i] for i in range(len(partitions))]
|
|
428
|
+
for i in range(len(partitions), axes.size):
|
|
429
|
+
flat_axes[i].set_visible(False)
|
|
405
430
|
if legend_inside:
|
|
406
|
-
for panel_ax in
|
|
431
|
+
for panel_ax in active_axes:
|
|
407
432
|
if panel_ax.get_legend() is not None:
|
|
408
433
|
panel_ax.get_legend().set_in_layout(False)
|
|
409
434
|
fig.tight_layout()
|
|
410
435
|
# V77 used tight_layout for facets; reserve generous gutters
|
|
411
436
|
# without changing V83's legend behaviour or column control.
|
|
412
437
|
fig.subplots_adjust(wspace=panel_wspace, hspace=panel_hspace)
|
|
413
|
-
_fit_inside_legends(fig,
|
|
438
|
+
_fit_inside_legends(fig, active_axes, legend_font_size, legend_columns)
|
|
414
439
|
else:
|
|
415
|
-
_place_outside_legend(fig,
|
|
440
|
+
_place_outside_legend(fig, active_axes,
|
|
416
441
|
legend_position, legend_font_size, legend_columns,
|
|
417
442
|
panel_hspace=panel_hspace, panel_wspace=panel_wspace)
|
|
418
443
|
return fig
|
|
@@ -453,27 +478,45 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
453
478
|
variable_names = list(ys)
|
|
454
479
|
x_names = list(spec.get("x_variables", [x]))
|
|
455
480
|
group_names = style_groups if group_col else []
|
|
481
|
+
# Reuse stable positions for per-variable markers and hatches.
|
|
482
|
+
x_position = {name: i for i, name in enumerate(x_names)}
|
|
483
|
+
y_position = {name: i for i, name in enumerate(ys)}
|
|
484
|
+
group_position = {name: i for i, name in enumerate(group_names)}
|
|
456
485
|
active = set()
|
|
457
486
|
if kind == "scatter":
|
|
458
487
|
scatter_markers = ['o', 's', 'D', 'x', '^', 'v', 'P', '*']
|
|
459
488
|
for key, group, y, xv, category, rows in entries:
|
|
460
489
|
g = group if group_col else None
|
|
461
490
|
active.add((y, g))
|
|
491
|
+
# Use the same editable series keys as the appearance controls.
|
|
492
|
+
# Previously this multi-axis branch ignored all three overrides.
|
|
493
|
+
candidates = (key, f"{g}, {y}" if g is not None else str(y),
|
|
494
|
+
str(g) if g is not None else str(y), str(y))
|
|
495
|
+
def override(mapping, fallback):
|
|
496
|
+
return next((mapping[name] for name in candidates if name in mapping), fallback)
|
|
497
|
+
size = _bounded_float({"value": override(sizes, marker_size)}, "value", marker_size, 0.1, 100)
|
|
498
|
+
alpha = _bounded_float({"value": override(marker_alphas, 1)}, "value", 1, 0, 1)
|
|
499
|
+
default_marker = scatter_markers[x_position[xv] % len(scatter_markers)]
|
|
500
|
+
marker = (_scatter_marker({"value": override(markers, "dot")}, "value")
|
|
501
|
+
if any(name in markers for name in candidates) else default_marker)
|
|
462
502
|
ax.scatter(rows[xv], rows[y], color=color_map[(y, g)],
|
|
463
|
-
marker=
|
|
464
|
-
s=marker_size**2, alpha=.8)
|
|
503
|
+
marker=marker, s=size**2, alpha=alpha)
|
|
465
504
|
else:
|
|
466
505
|
cats = list(pd.unique(df[x].astype(str)))
|
|
467
506
|
combinations = [(g, y) for g in (group_names or ['All observations']) for y in ys]
|
|
468
507
|
n = max(1, len(combinations))
|
|
469
508
|
width = .82 / n
|
|
509
|
+
# Resolve categorical positions once rather than scanning both
|
|
510
|
+
# lists for every multi-axis trace. Keep the original ordering.
|
|
511
|
+
combination_index = {item: i for i, item in enumerate(combinations)}
|
|
512
|
+
category_index = {item: i for i, item in enumerate(cats)}
|
|
470
513
|
from collections import defaultdict
|
|
471
514
|
buckets = defaultdict(list)
|
|
472
515
|
for key, group, y, xv, category, rows in entries:
|
|
473
516
|
g = group if group_col else None
|
|
474
517
|
active.add((y, g))
|
|
475
|
-
idx =
|
|
476
|
-
pos =
|
|
518
|
+
idx = combination_index[(group, y)]
|
|
519
|
+
pos = category_index[category] + (idx - (n - 1)/2)*width
|
|
477
520
|
values = pd.to_numeric(rows[y], errors='coerce').dropna().to_numpy(dtype=float)
|
|
478
521
|
buckets[(pos, y, g)].extend(values)
|
|
479
522
|
for (pos, y, g), values in buckets.items():
|
|
@@ -481,7 +524,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
481
524
|
continue
|
|
482
525
|
color = color_map[(y, g)]
|
|
483
526
|
hatch_options = ['', '/', '\\', 'x', '.', '+', '-']
|
|
484
|
-
hatch_index =
|
|
527
|
+
hatch_index = y_position[y] if multi_y_color_by == 'group' else group_position.get(g, 0)
|
|
485
528
|
hatch = '' if multi_y_color_by in ('none', 'both') else hatch_options[hatch_index % len(hatch_options)]
|
|
486
529
|
if kind == 'bar':
|
|
487
530
|
ax.bar(pos, sum(values), width=width*.92*bar_box_width, color=color, hatch=hatch)
|
|
@@ -580,11 +623,14 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
580
623
|
elif spec["frequency"] is not None and id_col is None:
|
|
581
624
|
line_df = _add_gap_sections(df, x_column=x, group_columns=spec["groups"], frequency=spec["frequency"])
|
|
582
625
|
section_col = "__line_section"
|
|
626
|
+
# Partition the line data once, not once per Y variable. This
|
|
627
|
+
# preserves group order and the existing equality semantics while
|
|
628
|
+
# avoiding repeated full-column masks for multi-Y plots.
|
|
629
|
+
grouped_line_rows = ([(n, line_df.loc[line_df[group_col] == n]) for n in labels]
|
|
630
|
+
if group_col else None)
|
|
583
631
|
for y in ys:
|
|
584
|
-
if
|
|
585
|
-
|
|
586
|
-
else:
|
|
587
|
-
sets = [(str(y) if len(ys) > 1 else "All observations", line_df)]
|
|
632
|
+
sets = (grouped_line_rows if grouped_line_rows is not None else
|
|
633
|
+
[(str(y) if len(ys) > 1 else "All observations", line_df)])
|
|
588
634
|
for name, rows in sets:
|
|
589
635
|
rows = rows.loc[rows[y].notna()]
|
|
590
636
|
if rows.empty:
|
|
@@ -596,7 +642,9 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
|
|
|
596
642
|
key = (str(y), str(name) if group_col else None)
|
|
597
643
|
ax.plot(part[x], part[y], color=(multi_colors[key] if multi_y else colors.get(name, palette_values[0])),
|
|
598
644
|
linewidth=float(line_width),
|
|
599
|
-
linestyle=(
|
|
645
|
+
linestyle=(_line_style(styles, name) if not multi_y else
|
|
646
|
+
_line_style(styles, name) if name in styles and len(ys) == 1 else
|
|
647
|
+
multi_dashes[key]),
|
|
600
648
|
alpha=_bounded_float(alphas, name, 1, 0, 1),
|
|
601
649
|
label=("_nolegend_" if multi_y else rename.get(name, name)))
|
|
602
650
|
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
|