apmtools 0.2.44__tar.gz → 0.2.46__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 (22) hide show
  1. {apmtools-0.2.44 → apmtools-0.2.46}/PKG-INFO +1 -1
  2. {apmtools-0.2.44 → apmtools-0.2.46}/pyproject.toml +1 -1
  3. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/classes.py +3 -1
  4. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/appearance.py +2 -4
  5. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/data.py +8 -5
  6. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/matplotlib_renderer.py +50 -11
  7. apmtools-0.2.46/src/apmtools/shiny/multi_y.py +56 -0
  8. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/plotly_renderer.py +83 -4
  9. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/ui.py +584 -48
  10. {apmtools-0.2.44 → apmtools-0.2.46}/.gitignore +0 -0
  11. {apmtools-0.2.44 → apmtools-0.2.46}/LICENSE +0 -0
  12. {apmtools-0.2.44 → apmtools-0.2.46}/README.md +0 -0
  13. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/__init__.py +0 -0
  14. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/data_processing.py +0 -0
  15. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/functions.py +0 -0
  16. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/legacy.py +0 -0
  17. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/__init__.py +0 -0
  18. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/columns.py +0 -0
  19. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/config.py +0 -0
  20. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/export.py +0 -0
  21. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/summary.py +0 -0
  22. {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.44
3
+ Version: 0.2.46
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.44"
7
+ version = "0.2.46"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -861,7 +861,9 @@ class Dataset(DictionaryPlus):
861
861
  z[1].to_csv(output, index=False)
862
862
  out[z[0]] = z[1]
863
863
  if merged:
864
- return pd.concat([out[k] for k in out.keys()]).reset_index()
864
+ out_merged = pd.concat([out[k] for k in out.keys()]).reset_index()
865
+ out_merged.description = self.description
866
+ return out_merged
865
867
  return out
866
868
 
867
869
  def save_data(self, directory="./saved/", levels=[]):
@@ -41,9 +41,7 @@ def _label_plot_axes(fig, plot_type, x_column, y_columns, *,
41
41
  fig.update_yaxes(title_text=_axis_label(y_columns[0]))
42
42
  elif plot_type == "line" and len(y_columns) > 1:
43
43
  fig.update_xaxes(title_text=_axis_label(x_column))
44
- # The values axis is shared by multiple series; facet headings carry
45
- # the variable-specific unit rather than misleadingly naming one unit.
46
- fig.update_yaxes(title_text="Value")
44
+ fig.update_yaxes(title_text="<br>".join(_axis_label(y) for y in y_columns))
47
45
  for annotation in fig.layout.annotations:
48
46
  label = str(annotation.text)
49
47
  if label.startswith("variable="):
@@ -125,7 +123,7 @@ def _apply_plot_appearance(fig, *, title, x_label="", y_label="",
125
123
  if x_label.strip():
126
124
  fig.update_xaxes(title_text=x_label.strip())
127
125
  if y_label.strip():
128
- fig.update_yaxes(title_text=y_label.strip())
126
+ fig.update_yaxes(title_text=y_label.strip().replace("\n", "<br>"))
129
127
  fig.update_xaxes(showgrid=show_grid)
130
128
  fig.update_yaxes(showgrid=show_grid)
131
129
  use_x, use_y = _numeric_axis_controls(plot_kind, box_orientation)
@@ -131,8 +131,12 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate, *,
131
131
  is selected, matching Summary.group_stat(frequency=None, grouping_by=...).
132
132
  This function is called only when aggregation is explicitly requested.
133
133
  """
134
+ if data is None or data.empty:
135
+ return pd.DataFrame()
134
136
  if frequency is None and not grouping_by:
135
- raise ValueError("Select at least one categorical grouping variable for categorical-only aggregation.")
137
+ # Invalid/incomplete UI selection: no aggregation groups exist.
138
+ # Return empty rather than breaking Shiny's reactive graph.
139
+ return pd.DataFrame()
136
140
 
137
141
  result = data.group_stat(
138
142
  value_cols=value_columns,
@@ -143,10 +147,9 @@ def _aggregate(data, value_columns, frequency, grouping_by, aggregate, *,
143
147
  if frequency is not None else {}),
144
148
  )
145
149
  if result is None:
146
- raise ValueError(
147
- "Summary.group_stat() returned None. Check the selected variables, "
148
- "grouping columns, frequency, and completeness settings."
149
- )
150
+ # group_stat() may return None when no rows survive completeness
151
+ # requirements or other filtering. Treat this as an empty plot.
152
+ return pd.DataFrame()
150
153
  return result
151
154
 
152
155
 
@@ -27,7 +27,7 @@ def _matplotlib_defaults(spec, *, palette="Default"):
27
27
  x_label = (" | ".join(_axis_label(c) for c in spec["categories"])
28
28
  if kind in ("box", "bar") else _axis_label(x))
29
29
  y_label = ("Probability density" if kind == "histogram" else
30
- "Value" if kind == "line" and len(ys) > 1 else
30
+ "\n".join(_axis_label(y) for y in ys) if kind == "line" and len(ys) > 1 else
31
31
  _axis_label(ys[0]) if ys else "Count")
32
32
  x_label = spec.get("x_label_override") or x_label
33
33
  return dict(x_label=x_label, y_label=y_label, names=names, colors=colors)
@@ -322,12 +322,12 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
322
322
  legend_labels="", trace_colors="", palette="Default",
323
323
  x_min="", x_max="", y_min="", y_max="",
324
324
  template="plotly", legend_position="right",
325
- legend_inside=False, legend_columns=1, show_grid=True, font_size=12,
325
+ legend_inside=False, legend_columns=1, multi_y_color_by="group", show_grid=True, font_size=12,
326
326
  line_width=2, marker_size=7, line_alpha="", line_style="",
327
327
  scatter_size="", scatter_marker="", scatter_alpha="", hist_alpha="",
328
328
  box_showfliers=True, box_orientation="vertical",
329
329
  title_font_size=14, axis_font_size=12, tick_font_size=10,
330
- legend_font_size=12, panel_font_size=14, bins=30, density=False,
330
+ legend_font_size=12, panel_font_size=14, plot_dpi=100, bins=30, density=False,
331
331
  stacked=False, histtype="bar", cumulative=0,
332
332
  panel_by=None, independent_panel_limits=True, panel_titles="", _axes=None,
333
333
  x_major_ticks=6, y_major_ticks=6, x_minor_ticks=False, y_minor_ticks=False):
@@ -339,7 +339,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
339
339
  from matplotlib.patches import Patch
340
340
 
341
341
  if _axes is None:
342
- fig, ax = plt.subplots(figsize=(10, 5), dpi=100)
342
+ fig, ax = plt.subplots(figsize=(10, 5), dpi=plot_dpi)
343
343
  else:
344
344
  ax = _axes
345
345
  fig = ax.figure
@@ -364,7 +364,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
364
364
  plt.close(fig)
365
365
  ncols = (2 if len(partitions) == 4 else min(3, len(partitions)))
366
366
  nrows = math.ceil(len(partitions) / ncols)
367
- fig, axes = plt.subplots(nrows, ncols, figsize=(5*ncols, 3.8*nrows), dpi=100,
367
+ fig, axes = plt.subplots(nrows, ncols, figsize=(5*ncols, 3.8*nrows), dpi=plot_dpi,
368
368
  squeeze=False, sharex=False, sharey=False)
369
369
  for idx, (panel_title, panel_df, other_groups) in enumerate(partitions):
370
370
  panel_spec = {**spec, "data": panel_df, "groups": other_groups,
@@ -376,13 +376,13 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
376
376
  x_min=_panel_limit_value(x_min, panel_title), x_max=_panel_limit_value(x_max, panel_title),
377
377
  y_min=_panel_limit_value(y_min, panel_title), y_max=_panel_limit_value(y_max, panel_title),
378
378
  template=template, legend_position=legend_position,
379
- legend_inside=legend_inside, legend_columns=legend_columns, show_grid=show_grid, font_size=font_size,
379
+ legend_inside=legend_inside, legend_columns=legend_columns, multi_y_color_by=multi_y_color_by, show_grid=show_grid, font_size=font_size,
380
380
  line_width=line_width, marker_size=marker_size, line_alpha=line_alpha, line_style=line_style,
381
381
  scatter_size=scatter_size, scatter_marker=scatter_marker, scatter_alpha=scatter_alpha,
382
382
  hist_alpha=hist_alpha, box_showfliers=box_showfliers, box_orientation=box_orientation,
383
383
  title_font_size=panel_font_size, axis_font_size=axis_font_size,
384
384
  tick_font_size=tick_font_size, legend_font_size=legend_font_size,
385
- panel_font_size=panel_font_size,
385
+ panel_font_size=panel_font_size, plot_dpi=plot_dpi,
386
386
  bins=bins, density=density,
387
387
  stacked=stacked, histtype=histtype, cumulative=cumulative, _axes=panel_ax,
388
388
  x_major_ticks=x_major_ticks, y_major_ticks=y_major_ticks,
@@ -413,6 +413,11 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
413
413
  labels = defaults["names"] if group_col else ([str(y) for y in ys] if len(ys) > 1 else ["All observations"])
414
414
  colors = {name: overrides.get(name, defaults["colors"].get(name, palette_values[i % len(palette_values)]))
415
415
  for i, name in enumerate(labels)}
416
+ multi_y = kind == "line" and len(ys) > 1
417
+ if multi_y:
418
+ from .multi_y import multi_y_styles
419
+ multi_colors, multi_dashes, variable_names, group_names = multi_y_styles(
420
+ ys, labels if group_col else [], palette_values, multi_y_color_by, overrides, styles)
416
421
  for name, color in list(colors.items()):
417
422
  if not mcolors.is_color_like(color):
418
423
  colors[name] = defaults["colors"].get(name, palette_values[0])
@@ -480,11 +485,12 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
480
485
  parts = (rows.groupby(segment_col, dropna=False, sort=False) if segment_col else [(None, rows)])
481
486
  for _, part in parts:
482
487
  part = part.sort_values(x)
483
- ax.plot(part[x], part[y], color=colors.get(name, palette_values[0]),
488
+ key = (str(y), str(name) if group_col else None)
489
+ ax.plot(part[x], part[y], color=(multi_colors[key] if multi_y else colors.get(name, palette_values[0])),
484
490
  linewidth=float(line_width),
485
- linestyle=_line_style(styles, name),
491
+ linestyle=(multi_dashes[key] if multi_y else _line_style(styles, name)),
486
492
  alpha=_bounded_float(alphas, name, 1, 0, 1),
487
- label=rename.get(name, name))
493
+ label=("_nolegend_" if multi_y else rename.get(name, name)))
488
494
  elif kind in ("box", "bar"):
489
495
  # Build categorical groups in a single pass. The former implementation
490
496
  # repeatedly scanned the full DataFrame for every category × group,
@@ -593,6 +599,39 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
593
599
  unique = dict(zip(texts, handles))
594
600
  if unique and not spec.get("_hide_ungrouped_legend", not bool(spec["groups"])):
595
601
  ax.legend(unique.values(), unique.keys(), title="Legend")
602
+ if multi_y:
603
+ from matplotlib.lines import Line2D
604
+ # Independent legend keys for the two dimensions, with explicit headings.
605
+ legend_handles = []
606
+ legend_labels_out = []
607
+ if multi_y_color_by == "none":
608
+ for group in group_names if group_col else []:
609
+ key = (variable_names[0], group)
610
+ legend_handles.append(Line2D([], [], color=multi_colors[key], linestyle=multi_dashes[key], linewidth=line_width))
611
+ legend_labels_out.append(rename.get(group, group))
612
+ if multi_y_color_by == "both":
613
+ for group in (group_names or [None]):
614
+ for variable in variable_names:
615
+ key = (variable, group)
616
+ label = f"{group}, {variable}" if group is not None else variable
617
+ legend_handles.append(Line2D([], [], color=multi_colors[key], linestyle=multi_dashes[key], linewidth=line_width))
618
+ legend_labels_out.append(rename.get(label, label))
619
+ added_section = False
620
+ for heading, keys, by_var in (() if multi_y_color_by in ("both", "none") else (("Y variable", variable_names, True), ("Grouping", group_names, False))):
621
+ if not keys or (not by_var and not group_col):
622
+ continue
623
+ if added_section:
624
+ legend_handles.append(Line2D([], [], color="none"))
625
+ legend_labels_out.append(" ")
626
+ added_section = True
627
+ legend_handles.append(Line2D([], [], color="none"))
628
+ legend_labels_out.append(heading)
629
+ for value in keys:
630
+ key = (value, group_names[0] if group_col else None) if by_var else (variable_names[0], value)
631
+ legend_handles.append(Line2D([], [], color=multi_colors[key], linestyle=multi_dashes[key], linewidth=line_width))
632
+ legend_labels_out.append(rename.get(value, value))
633
+ if legend_handles:
634
+ ax.legend(legend_handles, legend_labels_out, title=None)
596
635
  legend = ax.get_legend()
597
636
  if legend is not None:
598
637
  locs = {"right":"upper right", "left":"upper left", "top":"upper center", "bottom":"lower center"}
@@ -671,7 +710,7 @@ def _matplotlib_figure(plotly_figure):
671
710
  import matplotlib.colors as mcolors
672
711
  from matplotlib.patches import Patch
673
712
 
674
- fig, ax = plt.subplots(figsize=(10, 5), dpi=100)
713
+ fig, ax = plt.subplots(figsize=(10, 5), dpi=plot_dpi)
675
714
  source = plotly_figure
676
715
  category_labels = list(getattr(source.layout.xaxis, "categoryarray", None) or [])
677
716
  categorical = (getattr(source.layout.xaxis, "type", None) == "category")
@@ -0,0 +1,56 @@
1
+ """Independent categorical colour and line-style encodings for multi-Y lines."""
2
+
3
+
4
+ def multi_y_styles(variables, groups, palette, mode="group", colors=None, styles=None):
5
+ variables = [str(v) for v in variables]
6
+ groups = [str(g) for g in groups] if groups else []
7
+ colors = colors or {}
8
+ styles = styles or {}
9
+ palette = list(palette) or ["#636EFA", "#EF553B", "#00CC96"]
10
+ combinations = [(var, group) for group in (groups or [None]) for var in variables]
11
+ if mode == "none":
12
+ # Restore original single-Y group encoding across all Y variables.
13
+ color_map = {}
14
+ dash_map = {}
15
+ for var in variables:
16
+ for group in (groups or [None]):
17
+ idx = groups.index(group) if group is not None else 0
18
+ color_map[(var, group)] = colors.get(group, palette[idx % len(palette)]) if group is not None else palette[0]
19
+ style = styles.get(group, "-") if group is not None else "-"
20
+ dash_map[(var, group)] = {"solid":"-", "dashed":"--", "dotted":":", "dashdot":"-."}.get(style, style)
21
+ return color_map, dash_map, variables, groups
22
+ primary = groups if mode == "group" and groups else variables
23
+ secondary = variables if mode == "group" and groups else groups
24
+ dash_values = ["-", "--", ":", "-."]
25
+ color_map, dash_map = {}, {}
26
+ for var in variables:
27
+ for group in (groups or [None]):
28
+ p = group if mode == "group" and groups else var
29
+ q = var if mode == "group" and groups else group
30
+ if mode == "both":
31
+ index = combinations.index((var, group))
32
+ if index < len(palette):
33
+ default_color = palette[index]
34
+ else:
35
+ import colorsys
36
+ hue = (index * 0.618033988749895) % 1.0
37
+ rgb = colorsys.hsv_to_rgb(hue, 0.72, 0.88)
38
+ default_color = "#{:02x}{:02x}{:02x}".format(*(round(255 * c) for c in rgb))
39
+ combined = f"{group}, {var}" if group is not None else var
40
+ color_map[(var, group)] = colors.get(combined, default_color)
41
+ dash_map[(var, group)] = styles.get(combined, "-")
42
+ continue
43
+ color_map[(var, group)] = colors.get(p, colors.get(f"{group}, {var}", palette[primary.index(p) % len(palette)]))
44
+ # For colour-by-variable, the grouping dimension must control dashes.
45
+ # Do not let a variable-level style override erase that distinction.
46
+ default_dash = dash_values[secondary.index(q) % len(dash_values)] if q in secondary else "-"
47
+ # The Shiny appearance panel automatically populates each group with
48
+ # "solid". Those generated defaults must not collapse the
49
+ # group-specific dash cycle in colour-by-variable mode.
50
+ override = styles.get(q)
51
+ if mode == "variable" and groups and override in ("solid", "-"):
52
+ override = None
53
+ dash_map[(var, group)] = override if override is not None else default_dash
54
+ if dash_map[(var, group)] in ("solid", "dashed", "dotted", "dashdot"):
55
+ dash_map[(var, group)] = {"solid":"-", "dashed":"--", "dotted":":", "dashdot":"-."}[dash_map[(var, group)]]
56
+ return color_map, dash_map, variables, groups
@@ -228,7 +228,7 @@ def _lineplot_figure(plot_df, x_column, y_columns, *, group_columns, frequency,
228
228
  y="value",
229
229
  color=group_column,
230
230
  line_group=line_group_column,
231
- facet_row="variable" if len(y_columns) > 1 else None,
231
+ line_dash="variable" if len(y_columns) > 1 else None,
232
232
  )
233
233
  elif section_column is not None:
234
234
  # Preserve filtered-out gaps even when no colour grouping is selected.
@@ -238,7 +238,7 @@ def _lineplot_figure(plot_df, x_column, y_columns, *, group_columns, frequency,
238
238
  long_df = plot_df[[x_column, section_column, *y_columns]].melt(
239
239
  id_vars=[x_column, section_column], var_name="variable", value_name="value")
240
240
  fig = px.line(long_df, x=x_column, y="value", color="variable",
241
- line_group=section_column, facet_row="variable")
241
+ line_group=section_column)
242
242
  elif identifier_column is not None:
243
243
  # With no explicit grouping, retain the normal Y-variable
244
244
  # colouring while using identifier to keep each monitor's
@@ -263,7 +263,7 @@ def _lineplot_figure(plot_df, x_column, y_columns, *, group_columns, frequency,
263
263
  y="value",
264
264
  color="variable",
265
265
  line_group=identifier_column,
266
- facet_row="variable",
266
+ line_dash="variable",
267
267
  )
268
268
  elif section_column is not None:
269
269
  if len(y_columns) == 1:
@@ -286,9 +286,88 @@ def _lineplot_figure(plot_df, x_column, y_columns, *, group_columns, frequency,
286
286
  y="value",
287
287
  color="variable",
288
288
  line_group=section_column,
289
- facet_row="variable",
289
+ line_dash="variable",
290
290
  )
291
291
  else:
292
292
  fig = px.line(plot_df, x=x_column, y=y_columns)
293
293
  return fig
294
294
 
295
+
296
+
297
+ def _style_multi_y_legend(fig, spec, mode, palette, trace_colors, line_style, legend_labels):
298
+ """Give multi-Y line plots separate variable/group legend keys."""
299
+ from .appearance import _parse_label_map
300
+ from .multi_y import multi_y_styles
301
+ from plotly.colors import qualitative
302
+ import plotly.graph_objects as go
303
+
304
+ variables = [str(v) for v in spec["ys"]]
305
+ groups = [str(v) for v in spec["data"]["__plot_group"].dropna().unique()] if spec["groups"] else []
306
+ palettes = getattr(qualitative, palette, None) if palette != "Default" else None
307
+ colors, dashes, variables, groups = multi_y_styles(variables, groups, palettes or qualitative.Plotly,
308
+ mode, _parse_label_map(trace_colors), _parse_label_map(line_style))
309
+ rename = _parse_label_map(legend_labels)
310
+ for trace in fig.data:
311
+ if trace.type not in ("scatter", "scattergl"):
312
+ continue
313
+ name = str(trace.name or "")
314
+ # Plotly Express names traces "group, variable" when both encodings are present.
315
+ group = None
316
+ variable = None
317
+ if groups:
318
+ for g in groups:
319
+ for v in variables:
320
+ if name == f"{g}, {v}" or name == f"{v}, {g}":
321
+ group, variable = g, v
322
+ break
323
+ if variable is not None:
324
+ break
325
+ else:
326
+ variable = name if name in variables else next((v for v in variables if name.endswith(v)), None)
327
+ if variable is None:
328
+ continue
329
+ key = (variable, group)
330
+ trace.line.color = colors[key]
331
+ trace.line.dash = {"-":"solid", "--":"dash", ":":"dot", "-.":"dashdot"}.get(dashes[key], dashes[key])
332
+ trace.showlegend = False
333
+ trace.legendgroup = f"series:{group}:{variable}"
334
+ if mode == "none":
335
+ for group in groups:
336
+ key = (variables[0], group)
337
+ fig.add_trace(go.Scatter(
338
+ x=[None], y=[None], mode="lines", name=rename.get(group, group),
339
+ line=dict(color=colors[key], dash={"-":"solid", "--":"dash", ":":"dot", "-.":"dashdot"}.get(dashes[key], "solid")),
340
+ legendgroup=f"group:{group}", showlegend=True, hoverinfo="skip",
341
+ ))
342
+ fig.update_layout(showlegend=bool(groups))
343
+ return
344
+ if mode == "both":
345
+ for group in (groups or [None]):
346
+ for variable in variables:
347
+ key = (variable, group)
348
+ label = f"{group}, {variable}" if group is not None else variable
349
+ fig.add_trace(go.Scatter(
350
+ x=[None], y=[None], mode="lines", name=rename.get(label, label),
351
+ line=dict(color=colors[key], dash="solid"),
352
+ legendgroup=f"series:{group}:{variable}", showlegend=True, hoverinfo="skip",
353
+ ))
354
+ fig.update_layout(showlegend=True)
355
+ return
356
+ # Legend-only proxy traces avoid duplicating real observations and preserve hover data.
357
+ added_section = False
358
+ for heading, items, by_variable in (("Y variable", variables, True), ("Grouping", groups, False)):
359
+ if not items:
360
+ continue
361
+ if added_section:
362
+ # A blank legend row separating the variable and grouping keys.
363
+ fig.add_trace(go.Scatter(x=[None], y=[None], mode="lines", name=" ",
364
+ line=dict(color="rgba(0,0,0,0)", width=0),
365
+ showlegend=True, hoverinfo="skip", legendgroup="separator"))
366
+ added_section = True
367
+ for index, item in enumerate(items):
368
+ key = (item, groups[0] if groups else None) if by_variable else (variables[0], item)
369
+ fig.add_trace(go.Scatter(x=[None], y=[None], mode="lines", name=rename.get(item, item),
370
+ line=dict(color=colors[key], dash={"-":"solid", "--":"dash", ":":"dot", "-.":"dashdot"}.get(dashes[key], "solid")),
371
+ legendgroup=heading, legendgrouptitle_text=heading if index == 0 else None,
372
+ showlegend=True, hoverinfo="skip"))
373
+ fig.update_layout(showlegend=True)