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.
- {apmtools-0.2.44 → apmtools-0.2.46}/PKG-INFO +1 -1
- {apmtools-0.2.44 → apmtools-0.2.46}/pyproject.toml +1 -1
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/classes.py +3 -1
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/appearance.py +2 -4
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/data.py +8 -5
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/matplotlib_renderer.py +50 -11
- apmtools-0.2.46/src/apmtools/shiny/multi_y.py +56 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/plotly_renderer.py +83 -4
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/ui.py +584 -48
- {apmtools-0.2.44 → apmtools-0.2.46}/.gitignore +0 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/LICENSE +0 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/README.md +0 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.44 → apmtools-0.2.46}/src/apmtools/shiny/summary.py +0 -0
- {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.
|
|
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>
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
"
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|