Skip to content

Commit 35cc775

Browse files
committed
FEAT: show and filepath support in Array.plot()
1 parent 8f4c183 commit 35cc775

File tree

4 files changed

+115
-45
lines changed

4 files changed

+115
-45
lines changed

doc/source/changes/version_0_35.rst.inc

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ Syntax changes
1515
Backward incompatible changes
1616
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1717

18-
* other backward incompatible changes
18+
* Plots made with Array.plot() in a Python script will be shown by default,
19+
unless either the filepath (see below) or ax arguments are used. Shown plots
20+
will open a window and pause the running script until the window is closed by
21+
the user. To revert to the previous behavior, use show=False.
1922

2023

2124
New features
@@ -30,6 +33,12 @@ New features
3033

3134
will create an animated bar plot with one frame per year.
3235

36+
* implemented Array.plot `filepath` argument to save plots to a file directly,
37+
without having to use the matplotlib API.
38+
39+
* implemented Array.plot `show` argument to display plots directly, without
40+
having to use the matplotlib API. This is the new default behavior.
41+
3342
* added a feature (see the :ref:`miscellaneous section <misc>` for details). It works on :ref:`api-axis` and
3443
:ref:`api-group` objects.
3544

larray/core/array.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7116,8 +7116,6 @@ def to_clipboard(self, *args, **kwargs) -> None:
71167116
def plot(self) -> PlotObject:
71177117
r"""Plot the data of the array into a graph (window pop-up).
71187118
7119-
The graph can be tweaked to achieve the desired formatting and can be saved to a .png file.
7120-
71217119
Parameters
71227120
----------
71237121
kind : str
@@ -7132,6 +7130,14 @@ def plot(self) -> PlotObject:
71327130
- 'pie' : pie plot
71337131
- 'scatter' : scatter plot (if array's dimensions >= 2)
71347132
- 'hexbin' : hexbin plot (if array's dimensions >= 2)
7133+
filepath : str or Path, default None
7134+
Save plot as a file at `filepath`. Defaults to None (do not save).
7135+
When saving the plot to a file, the function returns None. In other
7136+
words, in that case, the plot is no longer available for further
7137+
tweaking or display.
7138+
show : bool, optional
7139+
Whether to display the plot directly.
7140+
Defaults to True if `filepath` is None and `ax` is None, False otherwise.
71357141
ax : matplotlib axes object, default None
71367142
subplots : boolean, Axis, int, str or tuple, default False
71377143
Make several subplots.
@@ -7232,29 +7238,26 @@ def plot(self) -> PlotObject:
72327238
72337239
Examples
72347240
--------
7235-
>>> import matplotlib.pyplot as plt
7236-
>>> # let us define an array with some made up data
7241+
Let us first define an array with some made up data
7242+
72377243
>>> import larray as la
7238-
>>> arr = la.Array([[5, 20, 5, 10], [6, 16, 8, 11]], 'gender=M,F;year=2018..2021')
7244+
>>> arr = la.Array([[5, 20, 5, 10],
7245+
... [6, 16, 8, 11]], 'gender=M,F;year=2018..2021')
72397246
72407247
Simple line plot
72417248
72427249
>>> arr.plot()
7243-
>>> # show figure (it also resets it after showing it! Do not call it before savefig)
7244-
>>> plt.show()
7250+
<Axes: xlabel='year'>
72457251
7246-
Line plot with grid and a title
7252+
Line plot with grid and a title, saved in a file
72477253
7248-
>>> arr.plot(grid=True, title='line plot')
7249-
>>> # save figure in a file (see matplotlib.pyplot.savefig documentation for more details)
7250-
>>> plt.savefig('my_file.png')
7254+
>>> arr.plot(grid=True, title='line plot', filepath='my_file.png')
72517255
72527256
2 bar plots (one for each gender) sharing the same y axis, which makes sub plots
72537257
easier to compare. By default sub plots are independant of each other and the axes
72547258
ranges are computed to "fit" just the data for their individual plot.
72557259
7256-
>>> arr.plot.bar(subplots='gender', sharey=True)
7257-
>>> plt.show()
7260+
>>> arr.plot.bar(subplots='gender', sharey=True) # doctest: +SKIP
72587261
72597262
A stacked bar plot (genders are stacked)
72607263
@@ -7263,10 +7266,11 @@ def plot(self) -> PlotObject:
72637266
An animated bar chart (with two bars). We set explicit y bounds via ylim so that the
72647267
same boundaries are used for the whole animation.
72657268
7266-
>>> arr.plot.bar(animate='year', ylim=(0, 22)) # doctest: +SKIP
7269+
>>> arr.plot.bar(animate='year', ylim=(0, 22), filepath='myanim.avi') # doctest: +SKIP
72677270
72687271
Create a figure containing 2 x 2 graphs
72697272
7273+
>>> import matplotlib.pyplot as plt
72707274
>>> # see matplotlib.pyplot.subplots documentation for more details
72717275
>>> fig, ax = plt.subplots(2, 2, figsize=(10, 8), tight_layout=True) # doctest: +SKIP
72727276
>>> # line plot with 2 curves (Males and Females) in the top left corner (0, 0)

larray/core/plot.py

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def _plot_array(array, *args, x=None, y=None, series=None, _x_axes_last=False, *
181181
@deprecate_kwarg('stacked', 'stack')
182182
def __call__(self, x=None, y=None, ax=None, subplots=False, layout=None, figsize=None,
183183
sharex=None, sharey=False, tight_layout=None, constrained_layout=None, title=None, legend=None,
184-
animate=None, filepath=None, **kwargs):
184+
animate=None, filepath=None, show=None, **kwargs):
185185
from matplotlib import pyplot as plt
186186

187187
array = self.array
@@ -200,14 +200,14 @@ def __call__(self, x=None, y=None, ax=None, subplots=False, layout=None, figsize
200200
kwargs['stacked'] = True
201201

202202
animate_axes, subplot_axes, x, y, series_axes = PlotObject._handle_x_y_axes(array.axes, animate, subplots, x, y)
203-
203+
if show is None:
204+
show = filepath is None and ax is None
204205
if constrained_layout is None and tight_layout is None:
205206
constrained_layout = True
206207

207208
if ax is None:
208209
fig = plt.figure(figsize=figsize, tight_layout=tight_layout, constrained_layout=constrained_layout)
209-
210-
if subplots:
210+
if subplots or layout is not None:
211211
if layout is None:
212212
subplots_shape = subplot_axes.shape
213213
if len(subplots_shape) > 2:
@@ -217,37 +217,33 @@ def __call__(self, x=None, y=None, ax=None, subplots=False, layout=None, figsize
217217
layout = subplot_axes.shape
218218
if sharex is None:
219219
sharex = True
220-
ax = fig.subplots(*layout, sharex=sharex, sharey=sharey)
220+
ax_to_return = fig.subplots(*layout, sharex=sharex, sharey=sharey)
221+
ax = ax_to_return if subplots else ax_to_return.flat[0]
221222
else:
222223
ax = fig.add_subplot()
224+
ax_to_return = ax
225+
else:
226+
fig = ax.figure
227+
ax_to_return = ax
223228

229+
anim_kwargs = kwargs.pop('anim_params', {})
224230
if animate:
225-
import matplotlib.animation as animation
231+
from matplotlib.animation import FuncAnimation
226232

227-
def run(t):
228-
if subplots:
233+
if subplots:
234+
def run(t):
229235
for subplot_ax in ax.flat:
230236
subplot_ax.clear()
231-
else:
237+
self._plot_many(array[t], ax, kwargs, series_axes, subplot_axes, title, x, y)
238+
else:
239+
def run(t):
232240
ax.clear()
233-
self._plot_many(array[t], ax, kwargs, series_axes, subplot_axes, title, x, y)
241+
self._plot_many(array[t], ax, kwargs, series_axes, subplot_axes, title, x, y)
234242
# TODO: add support for interpolation between frames/labels
235243
# see https://github.com/julkaar9/pynimate for inspiration
236-
ani = animation.FuncAnimation(fig, run, frames=animate_axes.iter_labels())
237-
if not isinstance(filepath, Path):
238-
filepath = Path(filepath)
239-
print(f"Writing animation to {filepath} ...", end=' ', flush=True)
240-
if '.htm' in filepath.suffix:
241-
filepath.write_text(f'<html>{ani.to_html5_video()}</html>', encoding='utf8')
242-
else:
243-
# writer = self.writer
244-
# if writer is None:
245-
writer = 'pillow' if filepath.suffix == '.gif' else 'ffmpeg'
246-
fps = 5
247-
metadata = None
248-
bitrate = None
249-
ani.save(filepath, writer=writer, fps=fps, metadata=metadata, bitrate=bitrate)
244+
ani = FuncAnimation(fig, run, frames=animate_axes.iter_labels())
250245
else:
246+
ani = None
251247
self._plot_many(array, ax, kwargs, series_axes, subplot_axes, title, x, y)
252248

253249
if legend or legend is None:
@@ -267,7 +263,56 @@ def run(t):
267263
# use figure to place legend to add a single legend for all subplots
268264
legend_parent = first_ax.figure if subplots else ax
269265
legend_parent.legend(handles, labels, **legend_kwargs)
270-
return ax
266+
267+
if filepath is not None:
268+
if ani is None:
269+
fig.savefig(filepath)
270+
else:
271+
if not isinstance(filepath, Path):
272+
filepath = Path(filepath)
273+
if filepath.suffix in {'.htm', '.html'}:
274+
# TODO: we should offer the option to use to_jshtml instead of to_html5_video. Even if it makes the
275+
# files (much) bigger (because they are stored as individual frames) it also adds some useful
276+
# play/pause/next frame/... buttons.
277+
filepath.write_text(f'<html>{ani.to_html5_video()}</html>', encoding='utf8')
278+
else:
279+
writer = anim_kwargs.pop('writer', None)
280+
fps = anim_kwargs.pop('fps', 5)
281+
metadata = anim_kwargs.pop('metadata', None)
282+
bitrate = anim_kwargs.pop('bitrate', None)
283+
if writer is None:
284+
# pillow only supports .gif, .png and .tiff ffmpeg supports .avi, .mov, .mp4 (but needs the
285+
# ffmpeg package installed)
286+
writer = 'pillow' if filepath.suffix in {'.gif', '.png', '.tiff'} else 'ffmpeg'
287+
from matplotlib.animation import writers
288+
if not writers.is_available(writer):
289+
raise Exception(f"Cannot write animation using '{filepath.suffix}' extension "
290+
f"because '{writer}' writer is not available.\n"
291+
"Installing an optional package is probably necessary.")
292+
293+
ani.save(filepath, writer=writer, fps=fps, metadata=metadata, bitrate=bitrate)
294+
295+
if show:
296+
# The following line displays the plot window. Note however that
297+
# it is only blocking when no Qt loop is already running (i.e. we are
298+
# not running inside the editor).
299+
# When using the Qt backend this boils down to:
300+
# manager = fig.canvas.manager
301+
# manager.show()
302+
# if block:
303+
# manager.start_main_loop()
304+
# the last line just gets the current Qt QApplication instance (created during
305+
# the first canvas creation) and .exec() it
306+
plt.show(block=True)
307+
# It is important to return ani, because otherwise in the non-blocking case
308+
# (i.e. when run in the editor), the animation is garbage-collected before
309+
# it is drawn, and we get a blank animation.
310+
return (ax_to_return, ani) if ani is not None else ax_to_return
311+
elif filepath is not None: # filepath and not show
312+
plt.close(fig)
313+
return None
314+
else: # no filepath and not show
315+
return (ax_to_return, ani) if ani is not None else ax_to_return
271316

272317
def _plot_many(self, array, ax, kwargs, series_axes, subplot_axes, title, x, y):
273318
if len(subplot_axes):

larray/tests/test_array.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44
import numpy as np
55
import pandas as pd
6+
import matplotlib.figure
67

78
from io import StringIO
89

@@ -5377,11 +5378,22 @@ def test_broadcast_with():
53775378

53785379

53795380
def test_plot():
5380-
pass
5381-
# small_h = small['c0']
5382-
# small_h.plot(kind='bar')
5383-
# small_h.plot()
5384-
# small_h.hist()
5381+
import matplotlib.pyplot as plt
5382+
5383+
fig, ax = plt.subplots() # doctest: +SKIP
5384+
assert isinstance(fig, matplotlib.figure.Figure)
5385+
arr = Array([[1, 5, 2],
5386+
[3, 2, 4]], axes='a=a0,a1;b=b0,b1,b2')
5387+
arr.plot(kind='bar', ax=ax)
5388+
fig.savefig('bar.png')
5389+
5390+
fig, ax = plt.subplots() # doctest: +SKIP
5391+
arr.plot(ax=ax)
5392+
fig.savefig('plot.png')
5393+
5394+
fig, ax = plt.subplots() # doctest: +SKIP
5395+
arr.plot.hist(ax=ax)
5396+
fig.savefig('hist.png')
53855397

53865398
# large_data = np.random.randn(1000)
53875399
# tick_v = np.random.randint(ord('a'), ord('z'), size=1000)

0 commit comments

Comments
 (0)