apmtools 0.2.30__tar.gz → 0.2.31__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.30
3
+ Version: 0.2.31
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.30"
7
+ version = "0.2.31"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -71,22 +71,69 @@ def _parse_label_map(value):
71
71
  return result
72
72
 
73
73
 
74
+ def _numeric_axis_controls(kind, box_orientation="vertical"):
75
+ """Return axes on which numeric/date tick-density controls apply."""
76
+ if kind in ("line", "scatter", "histogram"):
77
+ return True, True
78
+ if kind == "box" and box_orientation == "horizontal":
79
+ return True, False
80
+ return False, kind in ("box", "bar")
81
+
82
+
83
+ def _matplotlib_tick_controls(ax, *, kind, box_orientation="vertical", show_grid=True,
84
+ x_major_ticks=6, y_major_ticks=6,
85
+ x_minor_ticks=False, y_minor_ticks=False):
86
+ from matplotlib.ticker import MaxNLocator, AutoMinorLocator
87
+ from matplotlib.dates import AutoDateLocator
88
+ use_x, use_y = _numeric_axis_controls(kind, box_orientation)
89
+ for active, axis, count, minor in ((use_x, ax.xaxis, x_major_ticks, x_minor_ticks),
90
+ (use_y, ax.yaxis, y_major_ticks, y_minor_ticks)):
91
+ if not active:
92
+ continue
93
+ count = max(2, min(30, int(count or 6)))
94
+ if axis.get_scale() == "linear":
95
+ if isinstance(axis.get_major_locator(), AutoDateLocator):
96
+ axis.set_major_locator(AutoDateLocator(minticks=max(2, count - 2), maxticks=count))
97
+ else:
98
+ axis.set_major_locator(MaxNLocator(nbins=count))
99
+ if minor:
100
+ axis.set_minor_locator(AutoMinorLocator())
101
+ else:
102
+ axis.set_minor_locator(__import__("matplotlib.ticker", fromlist=["NullLocator"]).NullLocator())
103
+ axis.grid(bool(show_grid and minor), which="minor")
104
+
105
+
74
106
  def _apply_plot_appearance(fig, *, title, x_label="", y_label="",
75
107
  legend_labels="", trace_colors="", palette="Default",
76
108
  x_min="", x_max="", y_min="", y_max="",
77
109
  template="plotly", legend_position="right", legend_inside=False,
78
110
  show_grid=True, font_size=12, line_width=2,
79
- marker_size=7):
111
+ marker_size=7, line_alpha="", line_style="", scatter_size="", scatter_marker="", scatter_alpha="",
112
+ hist_alpha="", box_showfliers=True, box_orientation="vertical",
113
+ title_font_size=14, axis_font_size=12, tick_font_size=10,
114
+ legend_font_size=12, panel_font_size=14, plot_kind=None,
115
+ x_major_ticks=6, y_major_ticks=6, x_minor_ticks=False, y_minor_ticks=False):
80
116
  """Apply per-plot appearance overrides without changing the source data."""
81
117
  from plotly.colors import qualitative
82
118
 
83
- fig.update_layout(title=title, template=template, font=dict(size=font_size))
119
+ fig.update_layout(title=title, template=template, font=dict(size=font_size),
120
+ title_font=dict(size=title_font_size),
121
+ legend=dict(font=dict(size=legend_font_size), title_font=dict(size=legend_font_size)))
122
+ fig.update_xaxes(title_font=dict(size=axis_font_size), tickfont=dict(size=tick_font_size))
123
+ fig.update_yaxes(title_font=dict(size=axis_font_size), tickfont=dict(size=tick_font_size))
84
124
  if x_label.strip():
85
125
  fig.update_xaxes(title_text=x_label.strip())
86
126
  if y_label.strip():
87
127
  fig.update_yaxes(title_text=y_label.strip())
88
128
  fig.update_xaxes(showgrid=show_grid)
89
129
  fig.update_yaxes(showgrid=show_grid)
130
+ use_x, use_y = _numeric_axis_controls(plot_kind, box_orientation)
131
+ for active, axis_name, count, minor in ((use_x, "x", x_major_ticks, x_minor_ticks),
132
+ (use_y, "y", y_major_ticks, y_minor_ticks)):
133
+ if not active:
134
+ continue
135
+ update = getattr(fig, f"update_{axis_name}axes")
136
+ update(nticks=max(2, min(30, int(count or 6))), minor=dict(ticks="inside" if minor else "", showgrid=bool(show_grid and minor)))
90
137
  for axis, lower, upper in (("x", x_min, x_max), ("y", y_min, y_max)):
91
138
  datetime_axis = axis == "x" and any(
92
139
  getattr(trace, "type", "") == "scatter" and
@@ -112,9 +159,12 @@ def _apply_plot_appearance(fig, *, title, x_label="", y_label="",
112
159
  "bottom": dict(x=0.5, y=0.01, xanchor="center", yanchor="bottom", orientation="h"),
113
160
  }.get(legend_position, {})
114
161
  legend_settings = {**legend_settings, "bgcolor": "rgba(255,255,255,0.7)"}
115
- fig.update_layout(legend={**legend_settings, "title": {"text": "Legend"}})
162
+ fig.update_layout(legend={**legend_settings, "title": {"text": "Legend", "font": {"size": legend_font_size}}, "font": {"size": legend_font_size}})
116
163
  labels = _parse_label_map(legend_labels)
117
164
  colors = _parse_label_map(trace_colors)
165
+ alphas, styles, sizes, markers, marker_alphas = _series_style_maps(
166
+ line_alpha, line_style, scatter_size, scatter_marker, scatter_alpha)
167
+ histogram_alphas = _parse_label_map(hist_alpha)
118
168
  palette_values = getattr(qualitative, palette, None) if palette != "Default" else None
119
169
  # Color keys can refer to either original or renamed trace labels.
120
170
  for i, trace in enumerate(fig.data):
@@ -134,13 +184,57 @@ def _apply_plot_appearance(fig, *, title, x_label="", y_label="",
134
184
  trace.marker.color = color
135
185
  if getattr(trace, "fillcolor", None) is not None and trace.type == "histogram":
136
186
  trace.marker.color = color
137
- if getattr(trace, "line", None) is not None:
187
+ if plot_kind == "histogram":
188
+ trace.opacity = _bounded_float(histogram_alphas, original, 0.65, 0, 1)
189
+ if plot_kind == "box":
190
+ trace.boxpoints = "outliers" if box_showfliers else False
191
+ if box_orientation == "horizontal":
192
+ old_x, old_y = trace.x, trace.y
193
+ trace.x, trace.y = old_y, old_x
194
+ trace.orientation = "h"
195
+ if getattr(trace, "line", None) is not None and plot_kind == "line":
138
196
  trace.line.width = line_width
197
+ if plot_kind == "line":
198
+ trace.line.dash = {"-": "solid", ":": "dot", "--": "dash"}[_line_style(styles, original)]
199
+ trace.opacity = _bounded_float(alphas, original, 1, 0, 1)
139
200
  if getattr(trace, "marker", None) is not None and trace.type in ("scatter", "scattergl"):
140
- trace.marker.size = marker_size
201
+ if plot_kind == "scatter":
202
+ trace.marker.size = _bounded_float(sizes, original, 7, 0.1, 100)
203
+ trace.marker.symbol = {"o": "circle", "x": "x"}[_scatter_marker(markers, original)]
204
+ trace.opacity = _bounded_float(marker_alphas, original, 1, 0, 1)
205
+ else:
206
+ trace.marker.size = marker_size
207
+ if plot_kind == "box" and box_orientation == "horizontal":
208
+ x_title = fig.layout.xaxis.title.text
209
+ y_title = fig.layout.yaxis.title.text
210
+ fig.update_xaxes(title_text=y_title, type="linear", autorange=True)
211
+ fig.update_yaxes(title_text=x_title, type="category", autorange=True)
141
212
  return fig
142
213
 
143
214
 
215
+ def _series_style_maps(line_alpha="", line_style="", scatter_size="", scatter_marker="", scatter_alpha=""):
216
+ """Parse per-trace styles, keyed by the original legend/series name."""
217
+ return tuple(_parse_label_map(v) for v in (line_alpha, line_style, scatter_size, scatter_marker, scatter_alpha))
218
+
219
+
220
+ def _bounded_float(mapping, name, default, low, high):
221
+ try:
222
+ value = float(mapping.get(name, default))
223
+ return value if np.isfinite(value) and low <= value <= high else float(default)
224
+ except (ValueError, TypeError):
225
+ return float(default)
226
+
227
+
228
+ def _line_style(mapping, name):
229
+ return {"solid": "-", "dotted": ":", "dashed": "--", "-": "-", ":": ":", "--": "--"}.get(
230
+ mapping.get(name, "solid").strip().lower(), "-")
231
+
232
+
233
+ def _scatter_marker(mapping, name):
234
+ return {"dot": "o", "cross": "x", "o": "o", "x": "x"}.get(
235
+ mapping.get(name, "dot").strip().lower(), "o")
236
+
237
+
144
238
  PLOT_TYPES = {
145
239
  "bar": "Bar",
146
240
  "box": "Boxplot",
@@ -533,6 +627,10 @@ def _boxplot_figure(data, x_column, y_column, *, group_column=None):
533
627
  box_width = 0.82 / n_traces
534
628
  for i, trace in enumerate(fig.data):
535
629
  trace.width = box_width
630
+ # A single ungrouped box trace does not convey useful legend information.
631
+ if group_column is None and len(fig.data) == 1:
632
+ fig.update_traces(showlegend=False)
633
+ fig.update_layout(showlegend=False)
536
634
  return fig
537
635
 
538
636
 
@@ -1012,9 +1110,14 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
1012
1110
  x_min="", x_max="", y_min="", y_max="",
1013
1111
  template="plotly", legend_position="right",
1014
1112
  legend_inside=False, show_grid=True, font_size=12,
1015
- line_width=2, marker_size=7, bins=30, density=False,
1113
+ line_width=2, marker_size=7, line_alpha="", line_style="",
1114
+ scatter_size="", scatter_marker="", scatter_alpha="", hist_alpha="",
1115
+ box_showfliers=True, box_orientation="vertical",
1116
+ title_font_size=14, axis_font_size=12, tick_font_size=10,
1117
+ legend_font_size=12, panel_font_size=14, bins=30, density=False,
1016
1118
  stacked=False, histtype="bar", cumulative=0,
1017
- panel_by=None, independent_panel_limits=True, panel_titles="", _axes=None):
1119
+ panel_by=None, independent_panel_limits=True, panel_titles="", _axes=None,
1120
+ x_major_ticks=6, y_major_ticks=6, x_minor_ticks=False, y_minor_ticks=False):
1018
1121
  """Draw directly from the filtered/aggregated DataFrame, without Plotly."""
1019
1122
  import matplotlib
1020
1123
  matplotlib.use("Agg")
@@ -1051,7 +1154,8 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
1051
1154
  fig, axes = plt.subplots(nrows, ncols, figsize=(5*ncols, 3.8*nrows), dpi=100,
1052
1155
  squeeze=False, sharex=False, sharey=False)
1053
1156
  for idx, (panel_title, panel_df, other_groups) in enumerate(partitions):
1054
- panel_spec = {**spec, "data": panel_df, "groups": other_groups}
1157
+ panel_spec = {**spec, "data": panel_df, "groups": other_groups,
1158
+ "_hide_ungrouped_legend": not bool(other_groups)}
1055
1159
  panel_ax = axes.flat[idx]
1056
1160
  _matplotlib_direct_figure(panel_spec, title=panel_title_map.get(panel_title, panel_title), x_label=x_label,
1057
1161
  y_label=y_label, legend_labels=legend_labels, trace_colors=trace_colors,
@@ -1060,8 +1164,16 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
1060
1164
  y_min=_panel_limit_value(y_min, panel_title), y_max=_panel_limit_value(y_max, panel_title),
1061
1165
  template=template, legend_position=legend_position,
1062
1166
  legend_inside=legend_inside, show_grid=show_grid, font_size=font_size,
1063
- line_width=line_width, marker_size=marker_size, bins=bins, density=density,
1064
- stacked=stacked, histtype=histtype, cumulative=cumulative, _axes=panel_ax)
1167
+ line_width=line_width, marker_size=marker_size, line_alpha=line_alpha, line_style=line_style,
1168
+ scatter_size=scatter_size, scatter_marker=scatter_marker, scatter_alpha=scatter_alpha,
1169
+ hist_alpha=hist_alpha, box_showfliers=box_showfliers, box_orientation=box_orientation,
1170
+ title_font_size=panel_font_size, axis_font_size=axis_font_size,
1171
+ tick_font_size=tick_font_size, legend_font_size=legend_font_size,
1172
+ panel_font_size=panel_font_size,
1173
+ bins=bins, density=density,
1174
+ stacked=stacked, histtype=histtype, cumulative=cumulative, _axes=panel_ax,
1175
+ x_major_ticks=x_major_ticks, y_major_ticks=y_major_ticks,
1176
+ x_minor_ticks=x_minor_ticks, y_minor_ticks=y_minor_ticks)
1065
1177
  for extra in list(axes.flat)[len(partitions):]:
1066
1178
  extra.set_visible(False)
1067
1179
  fig.tight_layout()
@@ -1070,6 +1182,9 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
1070
1182
  defaults = _matplotlib_defaults(spec, palette=palette)
1071
1183
  rename = _parse_label_map(legend_labels)
1072
1184
  overrides = _parse_label_map(trace_colors)
1185
+ alphas, styles, sizes, markers, marker_alphas = _series_style_maps(
1186
+ line_alpha, line_style, scatter_size, scatter_marker, scatter_alpha)
1187
+ histogram_alphas = _parse_label_map(hist_alpha)
1073
1188
  palette_values = _MPL_PALETTES.get(palette, _MPL_PALETTES["Default"])
1074
1189
  labels = defaults["names"] if group_col else ([str(y) for y in ys] if len(ys) > 1 else ["All observations"])
1075
1190
  colors = {name: overrides.get(name, defaults["colors"].get(name, palette_values[i % len(palette_values)]))
@@ -1093,20 +1208,34 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
1093
1208
  all_vals = np.concatenate(arrays) if arrays else np.array([])
1094
1209
  if len(all_vals):
1095
1210
  edges = np.histogram_bin_edges(all_vals, bins=max(1, int(bins)))
1096
- ax.hist(arrays, bins=edges, density=density, stacked=stacked or histtype == "barstacked",
1097
- histtype=("bar" if histtype == "barstacked" else histtype),
1098
- cumulative=cumulative if cumulative else False,
1099
- color=[colors[n] for n in labels],
1100
- label=[rename.get(n, n) for n in labels],
1101
- alpha=.65 if histtype != "step" else 1)
1211
+ _, _, hist_patches = ax.hist(
1212
+ arrays, bins=edges, density=density,
1213
+ stacked=stacked or histtype == "barstacked",
1214
+ histtype=("bar" if histtype == "barstacked" else histtype),
1215
+ cumulative=cumulative if cumulative else False,
1216
+ color=[colors[n] for n in labels],
1217
+ label=[rename.get(n, n) for n in labels])
1218
+ # Matplotlib returns one patch container per group for bar and
1219
+ # one polygon per group for step/stepfilled.
1220
+ for name, group_patches in zip(labels, hist_patches):
1221
+ alpha = _bounded_float(histogram_alphas, name,
1222
+ 1 if histtype == "step" else .65, 0, 1)
1223
+ if hasattr(group_patches, "set_alpha"):
1224
+ group_patches.set_alpha(alpha)
1225
+ else:
1226
+ for patch in group_patches:
1227
+ patch.set_alpha(alpha)
1102
1228
  defaults["y_label"] = ("Cumulative probability" if cumulative and density else
1103
1229
  "Cumulative count" if cumulative else
1104
1230
  "Probability density" if density else "Count")
1105
1231
  elif kind == "scatter":
1106
1232
  for name, rows in series():
1107
1233
  color = colors.get(name, palette_values[0])
1108
- ax.scatter(rows[x], rows[ys[0]], s=float(marker_size)**2, color=color,
1109
- label=rename.get(name, name))
1234
+ ax.scatter(rows[x], rows[ys[0]],
1235
+ s=_bounded_float(sizes, name, marker_size, 0.1, 100)**2,
1236
+ marker=_scatter_marker(markers, name),
1237
+ alpha=_bounded_float(marker_alphas, name, 1, 0, 1),
1238
+ color=color, label=rename.get(name, name))
1110
1239
  elif kind == "line":
1111
1240
  line_df = df
1112
1241
  id_col = "identifier" if "identifier" in df.columns else None
@@ -1125,7 +1254,10 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
1125
1254
  for _, part in parts:
1126
1255
  part = part.sort_values(x)
1127
1256
  ax.plot(part[x], part[y], color=colors.get(name, palette_values[0]),
1128
- linewidth=float(line_width), label=rename.get(name, name))
1257
+ linewidth=float(line_width),
1258
+ linestyle=_line_style(styles, name),
1259
+ alpha=_bounded_float(alphas, name, 1, 0, 1),
1260
+ label=rename.get(name, name))
1129
1261
  elif kind in ("box", "bar"):
1130
1262
  # Build categorical groups in a single pass. The former implementation
1131
1263
  # repeatedly scanned the full DataFrame for every category × group,
@@ -1189,13 +1321,25 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
1189
1321
  box_positions = sorted(buckets)
1190
1322
  bp = ax.boxplot([buckets[pos] for pos in box_positions],
1191
1323
  positions=box_positions, widths=width * .92,
1192
- manage_ticks=False, patch_artist=True)
1324
+ manage_ticks=False, patch_artist=True,
1325
+ showfliers=bool(box_showfliers),
1326
+ vert=(box_orientation != "horizontal"))
1193
1327
  for patch, position in zip(bp["boxes"], box_positions):
1194
1328
  patch.set_facecolor(bucket_colors[position])
1195
1329
  patch.set_alpha(.7)
1196
- ax.set_xticks(range(len(cats)), cats)
1197
- ax.set_xlim(-.5, max(.5, len(cats) - .5))
1198
- if len(group_names) <= 40:
1330
+ if kind == "box" and box_orientation == "horizontal":
1331
+ ax.set_yticks(range(len(cats)), cats)
1332
+ ax.set_ylim(-.5, max(.5, len(cats) - .5))
1333
+ else:
1334
+ ax.set_xticks(range(len(cats)), cats)
1335
+ ax.set_xlim(-.5, max(.5, len(cats) - .5))
1336
+ # A boxplot with a single ungrouped series does not need a legend.
1337
+ # Keep legends for genuinely grouped boxplots and for bar plots.
1338
+ show_category_legend = not (
1339
+ kind == "box" and len(group_names) == 1
1340
+ and group_names[0] == "All observations"
1341
+ )
1342
+ if len(group_names) <= 40 and show_category_legend and not spec.get("_hide_ungrouped_legend", not bool(spec["groups"])):
1199
1343
  ax.legend(handles=[Patch(facecolor=colors[name], label=rename.get(name, name))
1200
1344
  for name in group_names], title="Legend")
1201
1345
  else:
@@ -1204,7 +1348,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
1204
1348
  if kind not in ("box", "bar"):
1205
1349
  handles, texts = ax.get_legend_handles_labels()
1206
1350
  unique = dict(zip(texts, handles))
1207
- if unique:
1351
+ if unique and not spec.get("_hide_ungrouped_legend", not bool(spec["groups"])):
1208
1352
  ax.legend(unique.values(), unique.keys(), title="Legend")
1209
1353
  legend = ax.get_legend()
1210
1354
  if legend is not None:
@@ -1227,11 +1371,23 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
1227
1371
  ax.set_facecolor("#EBEBEB")
1228
1372
  elif template in ("plotly_white", "simple_white"):
1229
1373
  ax.set_facecolor("white")
1230
- ax.set_xlabel(x_label.strip() or defaults["x_label"], fontsize=font_size)
1231
- ax.set_ylabel(y_label.strip() or defaults["y_label"], fontsize=font_size)
1232
- ax.set_title(title, fontsize=font_size+2)
1233
- ax.tick_params(labelsize=max(6, font_size-2))
1374
+ xlabel = x_label.strip() or defaults["x_label"]
1375
+ ylabel = y_label.strip() or defaults["y_label"]
1376
+ if kind == "box" and box_orientation == "horizontal":
1377
+ xlabel, ylabel = ylabel, xlabel
1378
+ ax.set_xlabel(xlabel, fontsize=axis_font_size)
1379
+ ax.set_ylabel(ylabel, fontsize=axis_font_size)
1380
+ ax.set_title(title, fontsize=title_font_size)
1381
+ ax.tick_params(labelsize=tick_font_size)
1382
+ if legend is not None:
1383
+ for text in legend.get_texts():
1384
+ text.set_fontsize(legend_font_size)
1385
+ legend.get_title().set_fontsize(legend_font_size)
1234
1386
  ax.grid(bool(show_grid))
1387
+ _matplotlib_tick_controls(ax, kind=kind, box_orientation=box_orientation,
1388
+ show_grid=show_grid, x_major_ticks=x_major_ticks,
1389
+ y_major_ticks=y_major_ticks, x_minor_ticks=x_minor_ticks,
1390
+ y_minor_ticks=y_minor_ticks)
1235
1391
  for lo, hi, getter, setter in ((x_min, x_max, ax.get_xlim, ax.set_xlim),
1236
1392
  (y_min, y_max, ax.get_ylim, ax.set_ylim)):
1237
1393
  try:
@@ -1381,6 +1537,51 @@ def _matplotlib_figure(plotly_figure):
1381
1537
  fig.tight_layout()
1382
1538
  return fig
1383
1539
 
1540
+ def _standalone_matplotlib_script(spec, options):
1541
+ """Create a self-contained plotting script using this renderer's source.
1542
+
1543
+ The CSV holds the *prepared* data, after subsetting and aggregation.
1544
+ Only pandas, numpy and matplotlib are required to rerun the plot.
1545
+ """
1546
+ import inspect
1547
+ import pprint
1548
+
1549
+ dependencies = (
1550
+ _axis_label, _parse_label_map, _series_style_maps, _bounded_float,
1551
+ _line_style, _scatter_marker, _composite_labels,
1552
+ _frequency_timedelta, _add_gap_sections, _matplotlib_defaults,
1553
+ _panel_columns, _panel_partitions, _panel_limit_value,
1554
+ _parse_axis_limit, _numeric_axis_controls, _matplotlib_tick_controls,
1555
+ _matplotlib_direct_figure,
1556
+ )
1557
+ source = "\n\n".join(inspect.getsource(fn) for fn in dependencies)
1558
+ metadata = {key: spec.get(key) for key in
1559
+ ("kind", "x", "ys", "groups", "categories", "frequency")}
1560
+ date_columns = [str(c) for c in spec["data"].columns
1561
+ if pd.api.types.is_datetime64_any_dtype(spec["data"][c])]
1562
+ return (
1563
+ '# Generated by apmtools Summary.plot_app (Matplotlib backend).\n'
1564
+ '# Run: python plot_script.py\n'
1565
+ '# Dependencies: pip install pandas numpy matplotlib\n'
1566
+ 'from pathlib import Path\n'
1567
+ 'import pandas as pd\nimport numpy as np\n'
1568
+ 'from datetime import datetime, date, timedelta\n\n'
1569
+ 'VARIABLE_UNITS = ' + pprint.pformat(VARIABLE_UNITS, sort_dicts=True) + '\n'
1570
+ '_MPL_PALETTES = ' + pprint.pformat(_MPL_PALETTES, sort_dicts=True) + '\n\n'
1571
+ + source + '\n\n'
1572
+ + 'def main():\n'
1573
+ + ' csv_path = Path(__file__).resolve().parent / "plot_data.csv"\n'
1574
+ + ' frame = pd.read_csv(csv_path, parse_dates=' + repr(date_columns) + ')\n'
1575
+ + ' spec = ' + repr(metadata) + '\n'
1576
+ + ' spec["data"] = frame\n'
1577
+ + ' options = ' + repr(options) + '\n'
1578
+ + ' figure = _matplotlib_direct_figure(spec, **options)\n'
1579
+ + ' import matplotlib.pyplot as plt\n'
1580
+ + ' plt.savefig("plot.png")\n\n'
1581
+ + 'if __name__ == "__main__":\n main()\n'
1582
+ )
1583
+
1584
+
1384
1585
  def create_summary_app(
1385
1586
  data: pd.DataFrame,
1386
1587
  *,
@@ -1448,8 +1649,8 @@ def create_summary_app(
1448
1649
  def plot_tab_ui(initial_filters=None):
1449
1650
  initial_filters = initial_filters or {}
1450
1651
  return ui.div(
1451
- ui.layout_columns(
1452
- ui.div(
1652
+ ui.layout_sidebar(
1653
+ ui.sidebar(
1453
1654
  ui.card(ui.card_header("Plot filters"),
1454
1655
  ui.input_selectize("subset_columns", "Subset columns",
1455
1656
  choices={c: c for c in subset_columns},
@@ -1485,8 +1686,8 @@ def create_summary_app(
1485
1686
  ui.input_select("hist_cumulative", "Cumulative", choices={"0":"False","1":"True (ascending)","-1":"True (descending)"}, selected="0"),
1486
1687
  ),
1487
1688
  ),
1689
+ title="Plot filters / settings / aggregation", position="left", open="open", width="320px",
1488
1690
  ),
1489
- ui.div(
1490
1691
  ui.layout_sidebar(
1491
1692
  ui.sidebar(
1492
1693
  ui.input_text("title", "Plot title", value=""),
@@ -1512,18 +1713,42 @@ def create_summary_app(
1512
1713
  ui.input_select("legend_position", "Legend position", choices={k:k.title() for k in ("right", "left", "top", "bottom")}, selected="right"),
1513
1714
  ui.input_checkbox("legend_inside", "Place legend inside plot", value=False),
1514
1715
  ui.input_checkbox("show_grid", "Show grid", value=True),
1515
- ui.input_numeric("font_size", "Font size", value=12, min=6, max=36),
1516
- ui.input_numeric("line_width", "Line width", value=2, min=0.5, max=15, step=0.5),
1517
- ui.input_numeric("marker_size", "Marker size", value=7, min=1, max=30),
1716
+ ui.panel_conditional("input.plot_type === 'line' || input.plot_type === 'scatter' || input.plot_type === 'histogram' || (input.plot_type === 'box' && input.box_orientation === 'horizontal')",
1717
+ ui.input_numeric("x_major_ticks", "X-axis major ticks (approximate)", value=6, min=2, max=30, step=1),
1718
+ ui.input_checkbox("x_minor_ticks", "X-axis minor ticks / gridlines", value=False)),
1719
+ ui.panel_conditional("input.plot_type === 'line' || input.plot_type === 'scatter' || input.plot_type === 'histogram' || input.plot_type === 'bar' || (input.plot_type === 'box' && input.box_orientation !== 'horizontal')",
1720
+ ui.input_numeric("y_major_ticks", "Y-axis major ticks (approximate)", value=6, min=2, max=30, step=1),
1721
+ ui.input_checkbox("y_minor_ticks", "Y-axis minor ticks / gridlines", value=False)),
1722
+ ui.input_numeric("title_font_size", "Plot title font size", value=14, min=6, max=72),
1723
+ ui.input_numeric("axis_font_size", "Axis title font size", value=12, min=6, max=72),
1724
+ ui.input_numeric("tick_font_size", "Axis tick font size", value=10, min=6, max=72),
1725
+ ui.input_numeric("legend_font_size", "Legend font size", value=12, min=6, max=72),
1726
+ ui.panel_conditional("input.plot_type === 'line'",
1727
+ ui.input_numeric("line_width", "Line width", value=2, min=0.5, max=15, step=0.5)),
1728
+ (ui.input_numeric("panel_font_size", "Panel title font size", value=14, min=6, max=72) if backend == "matplotlib" else ui.div()),
1729
+ ui.panel_conditional("input.plot_type === 'histogram'",
1730
+ ui.input_text_area("hist_alpha", "Histogram alpha (name=0..1; ...)", value="", rows=4)),
1731
+ ui.panel_conditional("input.plot_type === 'box'",
1732
+ ui.input_checkbox("box_showfliers", "Show outliers (fliers)", value=True),
1733
+ ui.input_select("box_orientation", "Box orientation", choices={"vertical":"Vertical", "horizontal":"Horizontal"}, selected="vertical")),
1734
+ ui.panel_conditional("input.plot_type === 'line'",
1735
+ ui.input_text_area("line_alpha", "Line alpha (name=0..1; ...)", value="", rows=4),
1736
+ ui.input_text_area("line_style", "Line style (name=solid/dotted/dashed; ...)", value="", rows=4)),
1737
+ ui.panel_conditional("input.plot_type === 'scatter'",
1738
+ ui.input_text_area("scatter_size", "Marker size (name=size; ...)", value="", rows=4),
1739
+ ui.input_text_area("scatter_marker", "Marker type (name=dot/cross; ...)", value="", rows=4),
1740
+ ui.input_text_area("scatter_alpha", "Marker alpha (name=0..1; ...)", value="", rows=4)),
1518
1741
  title="Plot appearance", position="right", open="closed", width="320px",
1519
1742
  ),
1520
1743
  ui.card(ui.card_header("Plot"),
1521
1744
  ui.div((output_widget("plot", height="100%", width="100%") if backend == "plotly" else ui.output_plot("plot", height="100%", width="100%")),
1522
1745
  class_="summary-plot-container"),
1523
- ui.input_action_button("export_csv", "Export plotted data as CSV", class_="btn-primary mt-3")),
1746
+ ui.input_action_button("export_csv", "Export plotted data as CSV", class_="btn-primary mt-3"),
1747
+ ui.input_action_button("export_plot", "Export plot", class_="btn-secondary mt-3"),
1748
+ (ui.download_button("export_python", "Export as a Python file and associated data",
1749
+ class_="btn-secondary mt-3")
1750
+ if backend == "matplotlib" else ui.div())),
1524
1751
  ),
1525
- ),
1526
- col_widths=(3, 9),
1527
1752
  ),
1528
1753
  )
1529
1754
 
@@ -1577,6 +1802,50 @@ def create_summary_app(
1577
1802
  footer=ui.modal_button("Cancel"),
1578
1803
  ))
1579
1804
 
1805
+ @reactive.effect
1806
+ @reactive.event(input.export_plot)
1807
+ def show_plot_export_dialog():
1808
+ ui.modal_show(ui.modal(
1809
+ ui.input_numeric("plot_export_width", "Width (inches)", value=10, min=1, max=100, step=0.5),
1810
+ ui.input_numeric("plot_export_height", "Height (inches)", value=5, min=1, max=100, step=0.5),
1811
+ ui.input_numeric("plot_export_dpi", "Resolution (DPI)", value=150, min=30, max=1200, step=25),
1812
+ ui.download_button("download_plot_png", "Download plot.png", class_="btn-primary"),
1813
+ title="Export plot", easy_close=True,
1814
+ footer=ui.modal_button("Cancel"),
1815
+ ))
1816
+
1817
+ @render.download(filename="plot.png", media_type="image/png")
1818
+ def download_plot_png():
1819
+ import io
1820
+ width = float(input.plot_export_width())
1821
+ height = float(input.plot_export_height())
1822
+ dpi = int(input.plot_export_dpi())
1823
+ if not (1 <= width <= 100 and 1 <= height <= 100 and 30 <= dpi <= 1200):
1824
+ raise ValueError("Invalid plot export dimensions or resolution")
1825
+ if width * dpi * height * dpi > 100_000_000:
1826
+ raise ValueError("Export exceeds 100 megapixels; reduce size or DPI")
1827
+ if backend == "matplotlib":
1828
+ import matplotlib.pyplot as plt
1829
+ fig = _matplotlib_direct_figure(prepared_plot(), **matplotlib_options())
1830
+ try:
1831
+ fig.set_size_inches(width, height, forward=True)
1832
+ buffer = io.BytesIO()
1833
+ fig.savefig(buffer, format="png", dpi=dpi)
1834
+ yield buffer.getvalue()
1835
+ finally:
1836
+ plt.close(fig)
1837
+ else:
1838
+ # Plotly static image export requires kaleido.
1839
+ # Use the same styled figure as the interactive plot.
1840
+ fig = styled_plotly_figure()
1841
+ try:
1842
+ yield fig.to_image(format="png", width=round(width * dpi),
1843
+ height=round(height * dpi), scale=1)
1844
+ except Exception as exc:
1845
+ raise RuntimeError(
1846
+ 'Plotly PNG export requires kaleido: pip install kaleido'
1847
+ ) from exc
1848
+
1580
1849
  @render.download(filename=lambda: _safe_csv_filename(input.export_filename()))
1581
1850
  def download_csv():
1582
1851
  # Use the same post-filter/post-aggregation dataframe as the plot.
@@ -1659,7 +1928,7 @@ def create_summary_app(
1659
1928
  "input.multi_panel === true",
1660
1929
  ui.input_selectize("panel_by", "Panel grouping variables",
1661
1930
  choices={name: name for name in selected},
1662
- selected=[], multiple=True)))
1931
+ selected=selected, multiple=True)))
1663
1932
  return ui.div(*controls)
1664
1933
 
1665
1934
  @render.ui
@@ -1810,6 +2079,12 @@ def create_summary_app(
1810
2079
  else:
1811
2080
  fig = _lineplot_figure(plot_df, x_column, y_columns, group_columns=group_columns, frequency=frequency, column_map=column_map)
1812
2081
 
2082
+ if not group_columns:
2083
+ # Retain trace names/colours for appearance editing, but
2084
+ # suppress the redundant ungrouped legend.
2085
+ fig.update_traces(showlegend=False)
2086
+ fig.update_layout(showlegend=False)
2087
+
1813
2088
  _label_plot_axes(
1814
2089
  fig, selected_plot, x_column, y_columns,
1815
2090
  category_columns=x_columns if selected_plot in ("box", "bar") else None,
@@ -1838,7 +2113,7 @@ def create_summary_app(
1838
2113
  if (backend == "matplotlib" and selected_groups_for_legend
1839
2114
  and bool(_optional_input("multi_panel", False))):
1840
2115
  panel_variables_for_legend = (
1841
- list(_optional_input("panel_by", []) or [])
2116
+ (list(_optional_input("panel_by", []) or []) or selected_groups_for_legend)
1842
2117
  if len(selected_groups_for_legend) > 1
1843
2118
  else selected_groups_for_legend
1844
2119
  )
@@ -1862,7 +2137,7 @@ def create_summary_app(
1862
2137
  selected_groups = list(input.group_by() or [])
1863
2138
  if (backend == "matplotlib" and selected_groups and bool(_optional_input("multi_panel", False))
1864
2139
  and bool(_optional_input("independent_panel_limits", True))):
1865
- panel_variable = (list(_optional_input("panel_by", []) or []) if len(selected_groups) > 1
2140
+ panel_variable = ((list(_optional_input("panel_by", []) or []) or selected_groups) if len(selected_groups) > 1
1866
2141
  else selected_groups)
1867
2142
  panel_bounds = _panel_axis_bounds(
1868
2143
  spec, panel_variable,
@@ -1883,8 +2158,18 @@ def create_summary_app(
1883
2158
  f"{name}={name}" for name in names))
1884
2159
  ui.update_text_area("trace_colors", value="; ".join(
1885
2160
  f"{name}={defaults['colors'][name]}" for name in names))
2161
+ kind = spec["kind"]
2162
+ if kind == "line":
2163
+ ui.update_text_area("line_alpha", value="; ".join(f"{name}=1" for name in names))
2164
+ ui.update_text_area("line_style", value="; ".join(f"{name}=solid" for name in names))
2165
+ elif kind == "histogram":
2166
+ ui.update_text_area("hist_alpha", value="; ".join(f"{name}=0.65" for name in names))
2167
+ elif kind == "scatter":
2168
+ ui.update_text_area("scatter_size", value="; ".join(f"{name}=7" for name in names))
2169
+ ui.update_text_area("scatter_marker", value="; ".join(f"{name}=dot" for name in names))
2170
+ ui.update_text_area("scatter_alpha", value="; ".join(f"{name}=1" for name in names))
1886
2171
  if backend == "matplotlib":
1887
- panel_variables = (list(_optional_input("panel_by", []) or []) if len(selected_groups) > 1
2172
+ panel_variables = ((list(_optional_input("panel_by", []) or []) or selected_groups) if len(selected_groups) > 1
1888
2173
  else selected_groups)
1889
2174
  panel_names = ([name for name, _, _ in _panel_partitions(spec, panel_variables)]
1890
2175
  if selected_groups and bool(_optional_input("multi_panel", False)) else [])
@@ -1918,9 +2203,16 @@ def create_summary_app(
1918
2203
  ui.update_select("legend_position", selected="right")
1919
2204
  ui.update_checkbox("legend_inside", value=False)
1920
2205
  ui.update_checkbox("show_grid", value=True)
1921
- ui.update_numeric("font_size", value=12)
2206
+ for field, value in (("title_font_size", 14), ("axis_font_size", 12),
2207
+ ("tick_font_size", 10), ("legend_font_size", 12),
2208
+ ("panel_font_size", 14)):
2209
+ ui.update_numeric(field, value=value)
1922
2210
  ui.update_numeric("line_width", value=2)
1923
- ui.update_numeric("marker_size", value=7)
2211
+ ui.update_checkbox("box_showfliers", value=True)
2212
+ ui.update_select("box_orientation", selected="vertical")
2213
+ ui.update_text_area("hist_alpha", value="")
2214
+ for field in ("line_alpha", "line_style", "scatter_size", "scatter_marker", "scatter_alpha"):
2215
+ ui.update_text_area(field, value="")
1924
2216
  ui.update_numeric("bins", value=30)
1925
2217
  ui.update_select("hist_density", selected="False")
1926
2218
  ui.update_select("hist_stacked", selected="False")
@@ -1930,8 +2222,8 @@ def create_summary_app(
1930
2222
  ui.update_checkbox("flooring_timestamp", value=True)
1931
2223
 
1932
2224
  if backend == "plotly":
1933
- @render_plotly
1934
- def plot():
2225
+ @reactive.calc
2226
+ def styled_plotly_figure():
1935
2227
  import plotly.graph_objects as go
1936
2228
  fig = go.Figure(base_figure())
1937
2229
  return _apply_plot_appearance(
@@ -1942,35 +2234,74 @@ def create_summary_app(
1942
2234
  y_min=input.y_min(), y_max=input.y_max(),
1943
2235
  template=input.template(), legend_position=input.legend_position(),
1944
2236
  legend_inside=input.legend_inside(), show_grid=input.show_grid(),
1945
- font_size=input.font_size(), line_width=input.line_width(),
1946
- marker_size=input.marker_size(),
2237
+ x_major_ticks=input.x_major_ticks(), y_major_ticks=input.y_major_ticks(),
2238
+ x_minor_ticks=input.x_minor_ticks(), y_minor_ticks=input.y_minor_ticks(),
2239
+ line_width=(input.line_width() if input.plot_type() == "line" else 2),
2240
+ hist_alpha=input.hist_alpha(), box_showfliers=input.box_showfliers(),
2241
+ box_orientation=input.box_orientation(),
2242
+ title_font_size=input.title_font_size(), axis_font_size=input.axis_font_size(),
2243
+ tick_font_size=input.tick_font_size(), legend_font_size=input.legend_font_size(),
2244
+ line_alpha=input.line_alpha(), line_style=input.line_style(),
2245
+ scatter_size=input.scatter_size(), scatter_marker=input.scatter_marker(),
2246
+ scatter_alpha=input.scatter_alpha(), plot_kind=input.plot_type(),
1947
2247
  )
1948
- else:
1949
- @render.plot
2248
+
2249
+ @render_plotly
1950
2250
  def plot():
1951
- return _matplotlib_direct_figure(
1952
- prepared_plot(), title=input.title(),
2251
+ return styled_plotly_figure()
2252
+ else:
2253
+ @reactive.calc
2254
+ def matplotlib_options():
2255
+ # Shared with the export so that all appearance options,
2256
+ # including per-panel overrides, are reproduced exactly.
2257
+ return dict(title=input.title(),
1953
2258
  x_label=input.x_axis_label(), y_label=input.y_axis_label(),
1954
2259
  legend_labels=input.legend_labels(), trace_colors=input.trace_colors(),
1955
2260
  palette=input.palette(), x_min=input.x_min(), x_max=input.x_max(),
1956
2261
  y_min=input.y_min(), y_max=input.y_max(), template=input.template(),
1957
2262
  legend_position=input.legend_position(), legend_inside=input.legend_inside(),
1958
- show_grid=input.show_grid(), font_size=input.font_size(),
1959
- line_width=input.line_width(), marker_size=input.marker_size(),
2263
+ show_grid=input.show_grid(),
2264
+ x_major_ticks=input.x_major_ticks(), y_major_ticks=input.y_major_ticks(),
2265
+ x_minor_ticks=input.x_minor_ticks(), y_minor_ticks=input.y_minor_ticks(),
2266
+ title_font_size=input.title_font_size(), axis_font_size=input.axis_font_size(),
2267
+ tick_font_size=input.tick_font_size(), legend_font_size=input.legend_font_size(),
2268
+ panel_font_size=input.panel_font_size(), hist_alpha=input.hist_alpha(),
2269
+ box_showfliers=input.box_showfliers(), box_orientation=input.box_orientation(),
2270
+ line_width=(input.line_width() if input.plot_type() == "line" else 2), line_alpha=input.line_alpha(),
2271
+ line_style=input.line_style(), scatter_size=input.scatter_size(),
2272
+ scatter_marker=input.scatter_marker(), scatter_alpha=input.scatter_alpha(),
1960
2273
  bins=int(input.bins()) if input.plot_type() == "histogram" else 30,
1961
2274
  density=input.hist_density() == "True" if input.plot_type() == "histogram" else False,
1962
2275
  stacked=input.hist_stacked() == "True" if input.plot_type() == "histogram" else False,
1963
2276
  histtype=input.hist_type() if input.plot_type() == "histogram" else "bar",
1964
2277
  cumulative=int(input.hist_cumulative()) if input.plot_type() == "histogram" else 0,
1965
2278
  panel_by=(
1966
- (list(_optional_input("panel_by", []) or []) if len(list(input.group_by() or [])) > 1
2279
+ ((list(_optional_input("panel_by", []) or []) or list(input.group_by() or [])) if len(list(input.group_by() or [])) > 1
1967
2280
  else list(input.group_by() or []))
1968
2281
  if list(input.group_by() or []) and bool(_optional_input("multi_panel", False)) else None
1969
2282
  ),
1970
2283
  independent_panel_limits=(bool(_optional_input("independent_panel_limits", True))
1971
2284
  if input.group_by() and _optional_input("multi_panel", False) else True),
1972
- panel_titles=(_optional_input("panel_titles", "") if input.group_by() and _optional_input("multi_panel", False) else ""),
1973
- )
2285
+ panel_titles=(_optional_input("panel_titles", "") if input.group_by() and _optional_input("multi_panel", False) else ""), )
2286
+
2287
+ @render.download(filename="plot_export.zip", media_type="application/zip")
2288
+ def export_python():
2289
+ import io
2290
+ import zipfile
2291
+ spec = prepared_plot()
2292
+ if "error" in spec:
2293
+ raise ValueError(spec["error"])
2294
+ frame = pd.DataFrame(spec["data"])
2295
+ script = _standalone_matplotlib_script(spec, matplotlib_options())
2296
+ buffer = io.BytesIO()
2297
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
2298
+ archive.writestr("plot_data.csv", frame.to_csv(index=False))
2299
+ archive.writestr("plot_script.py", script)
2300
+ yield buffer.getvalue()
2301
+
2302
+ @render.plot
2303
+ def plot():
2304
+ return _matplotlib_direct_figure(prepared_plot(), **matplotlib_options())
1974
2305
 
1975
2306
  return filter_state
1976
2307
 
File without changes
File without changes
File without changes