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.
Files changed (29) hide show
  1. {apmtools-0.2.51 → apmtools-0.2.53}/PKG-INFO +1 -1
  2. {apmtools-0.2.51 → apmtools-0.2.53}/pyproject.toml +1 -1
  3. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/classes.py +3 -3
  4. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/plot_app.py +7 -2
  5. apmtools-0.2.53/src/apmtools/plot_configs.py +143 -0
  6. apmtools-0.2.53/src/apmtools/shiny/filter_config.py +22 -0
  7. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/matplotlib_renderer.py +64 -16
  8. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/multi_y.py +6 -0
  9. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/ui.py +452 -115
  10. {apmtools-0.2.51 → apmtools-0.2.53}/.gitignore +0 -0
  11. {apmtools-0.2.51 → apmtools-0.2.53}/LICENSE +0 -0
  12. {apmtools-0.2.51 → apmtools-0.2.53}/README.md +0 -0
  13. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/__init__.py +0 -0
  14. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/data_processing.py +0 -0
  15. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/functions.py +0 -0
  16. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/legacy.py +0 -0
  17. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/__init__.py +0 -0
  18. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/appearance.py +0 -0
  19. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/color_editor.py +0 -0
  20. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/columns.py +0 -0
  21. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/config.py +0 -0
  22. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/data.py +0 -0
  23. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/elements.py +0 -0
  24. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/export.py +0 -0
  25. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/histogram_multi.py +0 -0
  26. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/multi_axis.py +0 -0
  27. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/plotly_renderer.py +0 -0
  28. {apmtools-0.2.51 → apmtools-0.2.53}/src/apmtools/shiny/summary.py +0 -0
  29. {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.51
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>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apmtools"
7
- version = "0.2.51"
7
+ version = "0.2.53"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -1011,7 +1011,7 @@ class Group(pd.DataFrame):
1011
1011
 
1012
1012
  class Summary(pd.DataFrame):
1013
1013
 
1014
- _metadata = ['metadata', 'plot']
1014
+ _metadata = ['metadata', 'plots']
1015
1015
 
1016
1016
  @property
1017
1017
  def _constructor(self):
@@ -1026,7 +1026,7 @@ class Summary(pd.DataFrame):
1026
1026
  self.plots = None
1027
1027
 
1028
1028
  def __setattr__(self, name, value):
1029
- if name == "plot" and value is not None and not isinstance(value, list):
1029
+ if name == "plots" and value is not None and not isinstance(value, list):
1030
1030
  raise TypeError("Summary.plot must be None or a list")
1031
1031
  super().__setattr__(name, value)
1032
1032
 
@@ -1035,7 +1035,7 @@ class Summary(pd.DataFrame):
1035
1035
  self.variable = getattr(other, "variable", None)
1036
1036
  self.metadata = getattr(other, "metadata", set()).copy()
1037
1037
  self.description = getattr(other, "description", "")
1038
- self.plots = getattr(other, "plot", None)
1038
+ self.plots = getattr(other, "plots", None)
1039
1039
 
1040
1040
  return self
1041
1041
 
@@ -71,11 +71,15 @@ class PlotApp:
71
71
  # Do not change process-wide pandas settings here.
72
72
  # With older pandas and CoW disabled, a shallow copy may share writes.
73
73
  self.summary = summary.copy(deep=False)
74
+ # Explicitly retained presets belong to the source Summary. Avoid
75
+ # mutating a shared list accidentally through the working frame.
76
+ self.summary.plots = None if getattr(summary, "plots", None) is None else list(summary.plots)
74
77
 
75
78
  def create_app(self):
76
79
  """Build an app whose complete plotting workspace is replaced on load."""
77
80
  from shiny import App, module, reactive, render, ui
78
81
  from .shiny.ui import _build_summary_ui_server
82
+ from .plot_configs import PlotConfigManager
79
83
  import shinyswatch
80
84
 
81
85
  app_ui = ui.page_fluid(
@@ -150,16 +154,17 @@ class PlotApp:
150
154
  # Building inner_ui before entering @module.ui leaves toolbar
151
155
  # inputs unprefixed, while the module server listens for
152
156
  # dataset_N-add_tab / dataset_N-add_table.
157
+ manager = PlotConfigManager(self.original_summary)
153
158
  @module.ui
154
159
  def workspace_ui():
155
160
  inner_ui, _ = _build_summary_ui_server(
156
- data, **self.options, include_theme_picker=False)
161
+ data, **self.options, include_theme_picker=False, config_manager=manager)
157
162
  return inner_ui
158
163
 
159
164
  @module.server
160
165
  def workspace_server(input, output, session):
161
166
  _, inner_server = _build_summary_ui_server(
162
- data, **self.options, include_theme_picker=False)
167
+ data, **self.options, include_theme_picker=False, config_manager=manager)
163
168
  return inner_server(input, output, session)
164
169
 
165
170
  ui_factories[name] = workspace_ui
@@ -0,0 +1,143 @@
1
+ """Plot configuration lifecycle: reactive drafts and explicitly retained presets."""
2
+ from __future__ import annotations
3
+ import copy
4
+ import json
5
+ import uuid
6
+
7
+
8
+ def _json_ready(value):
9
+ """Require portable, plain JSON-compatible configuration values."""
10
+ return json.loads(json.dumps(value, allow_nan=False))
11
+
12
+
13
+ class PlotConfigManager:
14
+ """Keep live tab drafts separate from explicitly retained Summary.plots.
15
+
16
+ All mutations go through methods. When used within Shiny, each tab has
17
+ independent reactive signals for data and appearance changes. Retained
18
+ presets are snapshots and are never modified by draft updates.
19
+ """
20
+
21
+ def __init__(self, summary):
22
+ self.summary = summary
23
+ self.drafts = {}
24
+ self.revision = 0 # Backwards-compatible diagnostic counter, not reactive.
25
+ self._signals = {}
26
+ if getattr(summary, 'plots', None) is None:
27
+ summary.plots = []
28
+ if not isinstance(summary.plots, list):
29
+ raise TypeError('Summary.plots must be a list or None')
30
+
31
+ # Settings that affect filtering, grouping or aggregation. Everything else
32
+ # is treated as presentation state. Panel layout (panel_by) is
33
+ # presentation-only: it must not invalidate filtering or aggregation. Unknown settings remain in the full
34
+ # snapshot and are conservatively classified as appearance-only until
35
+ # explicitly added here.
36
+ DATA_KEYS = frozenset({
37
+ 'plot_type', 'x', 'y', 'numeric_subset_columns',
38
+ 'numeric_subset_bounds', 'subset_chain', 'datetime_start',
39
+ 'datetime_end', 'group_by', 'frequency', 'aggregate',
40
+ 'only_complete', 'flooring_timestamp', 'multi_y_color_by',
41
+ })
42
+
43
+ def _signal(self, tab_id, kind):
44
+ # Created only for a tab that is actually used. Shiny is imported
45
+ # lazily so the manager remains usable outside a Shiny session.
46
+ from shiny import reactive
47
+ signals = self._signals.setdefault(tab_id, {})
48
+ if kind not in signals:
49
+ signals[kind] = reactive.value(0)
50
+ return signals[kind]
51
+
52
+ def watch(self, tab_id, kind='all'):
53
+ """Declare a per-tab reactive dependency (all/data/appearance/visibility)."""
54
+ if kind not in ('all', 'data', 'appearance', 'visibility'):
55
+ raise ValueError('Unknown configuration signal')
56
+ return self._signal(tab_id, kind).get()
57
+
58
+ def update(self, tab_id, config):
59
+ config = _json_ready(config)
60
+ previous = self.drafts.get(tab_id)
61
+ if previous == config:
62
+ return False
63
+ self.drafts[tab_id] = config
64
+ self.revision += 1
65
+ changed = set(config) | set(previous or {})
66
+ changed = {k for k in changed if (previous or {}).get(k) != config.get(k)}
67
+ kinds = {'all'}
68
+ if changed & self.DATA_KEYS:
69
+ kinds.add('data')
70
+ if changed - self.DATA_KEYS:
71
+ kinds.add('appearance')
72
+ if 'visible_elements' in changed:
73
+ kinds.add('visibility')
74
+ # Only notify signals that have subscribers; don't create signals
75
+ # for every tab and category when nobody observes them.
76
+ for kind in kinds:
77
+ signal = self._signals.get(tab_id, {}).get(kind)
78
+ if signal is not None:
79
+ signal.set(signal.get() + 1)
80
+ return True
81
+
82
+ def get(self, tab_id, *, watch=False, kind='all'):
83
+ if watch:
84
+ self.watch(tab_id, kind)
85
+ return copy.deepcopy(self.drafts[tab_id])
86
+
87
+
88
+ def patch(self, tab_id, changes):
89
+ """Apply a partial UI change to the canonical tab configuration.
90
+
91
+ Retain unrelated fields (including settings in inactive sidebar tabs).
92
+ Return False for no-op updates; never mutate a retained preset.
93
+ """
94
+ if not isinstance(changes, dict):
95
+ raise TypeError('Configuration changes must be a dictionary')
96
+ current = self.get(tab_id) if tab_id in self.drafts else {}
97
+ current.update(_json_ready(changes))
98
+ return self.update(tab_id, current)
99
+
100
+ def value(self, tab_id, key, default=None, *, watch=False):
101
+ """Read a setting from one tab without exposing mutable state."""
102
+ if watch:
103
+ self.watch(tab_id)
104
+ return copy.deepcopy(self.drafts.get(tab_id, {}).get(key, default))
105
+
106
+ def discard(self, tab_id):
107
+ if tab_id in self.drafts:
108
+ del self.drafts[tab_id]
109
+ self.revision += 1
110
+ self._signals.pop(tab_id, None)
111
+
112
+ def retain(self, tab_id, name, *, preset_id=None):
113
+ """Persist only this explicitly chosen draft in Summary.plots."""
114
+ if tab_id not in self.drafts:
115
+ raise KeyError(f'No live configuration for {tab_id}')
116
+ name = str(name).strip()
117
+ if not name:
118
+ raise ValueError('Configuration name must not be empty')
119
+ preset_id = preset_id or uuid.uuid4().hex
120
+ item = {'schema_version': 1, 'id': preset_id, 'name': name,
121
+ 'type': 'plot', 'settings': self.get(tab_id)}
122
+ items = copy.deepcopy(self.summary.plots or [])
123
+ for i, old in enumerate(items):
124
+ if old.get('id') == preset_id:
125
+ items[i] = item
126
+ break
127
+ else:
128
+ items.append(item)
129
+ self.summary.plots = items
130
+ self.revision += 1
131
+ return preset_id
132
+
133
+ def saved(self):
134
+ return copy.deepcopy(self.summary.plots or [])
135
+
136
+ def restore(self, preset_id, tab_id):
137
+ item = next((p for p in self.saved() if p.get('id') == preset_id), None)
138
+ if item is None:
139
+ raise KeyError(preset_id)
140
+ if item.get('schema_version') != 1 or item.get('type') != 'plot':
141
+ raise ValueError('Unsupported saved configuration')
142
+ self.update(tab_id, item['settings'])
143
+ return self.get(tab_id)
@@ -0,0 +1,22 @@
1
+ """Pure transformations for canonical plot-filter configuration.
2
+
3
+ No Shiny reactive reads or writes belong here. Both the renderer-facing
4
+ filter state and explicit saved snapshots use this same mapping.
5
+ """
6
+ from __future__ import annotations
7
+ import copy
8
+
9
+
10
+ def filter_config_fields(state):
11
+ """Return independent JSON-compatible draft fields for effective filters.
12
+
13
+ The stage order and selected numerical-column order are preserved.
14
+ Copies prevent later UI mutations from altering a saved snapshot.
15
+ """
16
+ return {
17
+ "subset_chain": copy.deepcopy(state["stages"]),
18
+ "numeric_subset_bounds": copy.deepcopy(state["numeric_bounds"]),
19
+ "numeric_subset_columns": list(state["numeric_bounds"]),
20
+ "datetime_start": state["start"],
21
+ "datetime_end": state["end"],
22
+ }
@@ -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 names:
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
- for extra in list(axes.flat)[len(partitions):]:
404
- extra.set_visible(False)
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 list(axes.flat)[:len(partitions)]:
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, list(axes.flat)[:len(partitions)], legend_font_size, legend_columns)
438
+ _fit_inside_legends(fig, active_axes, legend_font_size, legend_columns)
414
439
  else:
415
- _place_outside_legend(fig, list(axes.flat)[:len(partitions)],
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=scatter_markers[x_names.index(xv) % len(scatter_markers)],
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 = combinations.index((group, y))
476
- pos = cats.index(category) + (idx - (n - 1)/2)*width
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 = ys.index(y) if multi_y_color_by == 'group' else (group_names.index(g) if g in group_names else 0)
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 group_col:
585
- sets = [(n, line_df.loc[line_df[group_col] == n]) for n in labels]
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=(multi_dashes[key] if multi_y else _line_style(styles, name)),
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