├── .gitignore ├── data ├── Teams.xlsx ├── Coaches.xlsx ├── Medals.xlsx ├── Athletes.xlsx └── EntriesGender.xlsx ├── img ├── sketch.jpg ├── tokyo2020.png └── 1024px-Olympic_rings_without_rims.svg.png ├── README.md ├── LICENSE ├── env.yml └── plotutils.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .ipynb_checkpoints/ 3 | -------------------------------------------------------------------------------- /data/Teams.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipv/MultipanelFigures/HEAD/data/Teams.xlsx -------------------------------------------------------------------------------- /img/sketch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipv/MultipanelFigures/HEAD/img/sketch.jpg -------------------------------------------------------------------------------- /data/Coaches.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipv/MultipanelFigures/HEAD/data/Coaches.xlsx -------------------------------------------------------------------------------- /data/Medals.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipv/MultipanelFigures/HEAD/data/Medals.xlsx -------------------------------------------------------------------------------- /img/tokyo2020.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipv/MultipanelFigures/HEAD/img/tokyo2020.png -------------------------------------------------------------------------------- /data/Athletes.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipv/MultipanelFigures/HEAD/data/Athletes.xlsx -------------------------------------------------------------------------------- /data/EntriesGender.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipv/MultipanelFigures/HEAD/data/EntriesGender.xlsx -------------------------------------------------------------------------------- /img/1024px-Olympic_rings_without_rims.svg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipv/MultipanelFigures/HEAD/img/1024px-Olympic_rings_without_rims.svg.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Making multipanel figures with Matplotlib 2 | 3 | Viktor Sip, 2021 4 | 5 | Short tutorial on making publication-ready multipanel figures with Matplotlib. The Jupyter notebook will guide you how to create the figure below: 6 | 7 | 8 | 9 | ## License 10 | 11 | - The text and code was written by Viktor Sip. The code is shared under [MIT License](LICENSE), and the non-code is shared under [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0/). 12 | - The dataset (`data/`) was created by [Arjun Prasad Sarkhel](https://www.kaggle.com/arjunprasadsarkhel) and is shared under [CC BY-SA 4.0 License](https://creativecommons.org/licenses/by-sa/4.0/). [Source on kaggle.com](https://www.kaggle.com/arjunprasadsarkhel/2021-olympics-in-tokyo). 13 | 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Viktor Sip 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /env.yml: -------------------------------------------------------------------------------- 1 | name: py38 2 | channels: 3 | - defaults 4 | dependencies: 5 | - _libgcc_mutex=0.1 6 | - _tflow_select=2.3.0 7 | - absl-py=0.12.0 8 | - aiohttp=3.7.4 9 | - argon2-cffi=20.1.0 10 | - astunparse=1.6.3 11 | - async-timeout=3.0.1 12 | - async_generator=1.10 13 | - attrs=20.3.0 14 | - backcall=0.2.0 15 | - blas=1.0 16 | - bleach=3.3.0 17 | - blinker=1.4 18 | - brotlipy=0.7.0 19 | - c-ares=1.17.1 20 | - ca-certificates=2021.7.5 21 | - cachetools=4.2.1 22 | - certifi=2021.5.30 23 | - cffi=1.14.5 24 | - chardet=3.0.4 25 | - click=7.1.2 26 | - cloudpickle=1.6.0 27 | - coverage=5.5 28 | - cryptography=3.4.7 29 | - cycler=0.10.0 30 | - cython=0.29.23 31 | - dbus=1.13.18 32 | - decorator=4.4.2 33 | - defusedxml=0.7.1 34 | - dill=0.3.3 35 | - entrypoints=0.3 36 | - et_xmlfile=1.1.0 37 | - expat=2.3.0 38 | - fontconfig=2.13.1 39 | - freetype=2.10.4 40 | - future=0.18.2 41 | - gast=0.3.3 42 | - glib=2.68.1 43 | - google-auth=1.29.0 44 | - google-auth-oauthlib=0.4.4 45 | - google-pasta=0.2.0 46 | - googleapis-common-protos=1.53.0 47 | - grpcio=1.36.1 48 | - gst-plugins-base=1.14.0 49 | - gstreamer=1.14.0 50 | - h5py=2.10.0 51 | - hdf5=1.10.6 52 | - icu=58.2 53 | - idna=2.10 54 | - importlib-metadata=3.10.0 55 | - importlib_metadata=3.10.0 56 | - intel-openmp=2020.2 57 | - ipykernel=5.3.4 58 | - ipython=7.22.0 59 | - ipython_genutils=0.2.0 60 | - ipywidgets=7.6.3 61 | - jdcal=1.4.1 62 | - jedi=0.17.0 63 | - jinja2=2.11.3 64 | - joblib=1.0.1 65 | - jpeg=9b 66 | - jsonschema=3.2.0 67 | - jupyter=1.0.0 68 | - jupyter_client=6.1.12 69 | - jupyter_console=6.4.0 70 | - jupyter_core=4.7.1 71 | - jupyterlab_pygments=0.1.2 72 | - jupyterlab_widgets=1.0.0 73 | - keras-preprocessing=1.1.2 74 | - kiwisolver=1.3.1 75 | - lcms2=2.12 76 | - ld_impl_linux-64=2.33.1 77 | - libffi=3.3 78 | - libgcc-ng=9.1.0 79 | - libgfortran-ng=7.3.0 80 | - libpng=1.6.37 81 | - libprotobuf=3.14.0 82 | - libsodium=1.0.18 83 | - libstdcxx-ng=9.1.0 84 | - libtiff=4.1.0 85 | - libuuid=1.0.3 86 | - libxcb=1.14 87 | - libxml2=2.9.10 88 | - lz4-c=1.9.3 89 | - markdown=3.3.4 90 | - markupsafe=1.1.1 91 | - matplotlib=3.3.4 92 | - matplotlib-base=3.3.4 93 | - mistune=0.8.4 94 | - mkl=2020.2 95 | - mkl-service=2.3.0 96 | - mkl_fft=1.3.0 97 | - mkl_random=1.1.1 98 | - multidict=5.1.0 99 | - nbclient=0.5.3 100 | - nbconvert=6.0.7 101 | - nbformat=5.1.3 102 | - ncurses=6.2 103 | - nest-asyncio=1.5.1 104 | - networkx=2.5.1 105 | - notebook=6.3.0 106 | - numpy=1.19.2 107 | - numpy-base=1.19.2 108 | - oauthlib=3.1.0 109 | - olefile=0.46 110 | - openpyxl=3.0.7 111 | - openssl=1.1.1l 112 | - opt_einsum=3.1.0 113 | - packaging=20.9 114 | - pandas=1.2.4 115 | - pandoc=2.12 116 | - pandocfilters=1.4.3 117 | - parso=0.8.2 118 | - pcre=8.44 119 | - pexpect=4.8.0 120 | - pickleshare=0.7.5 121 | - pillow=8.2.0 122 | - pip=21.0.1 123 | - prometheus_client=0.10.1 124 | - promise=2.3 125 | - prompt-toolkit=3.0.17 126 | - prompt_toolkit=3.0.17 127 | - psutil=5.8.0 128 | - ptyprocess=0.7.0 129 | - pyasn1=0.4.8 130 | - pyasn1-modules=0.2.8 131 | - pycparser=2.20 132 | - pygments=2.8.1 133 | - pyjwt=1.7.1 134 | - pyopenssl=20.0.1 135 | - pyparsing=2.4.7 136 | - pyqt=5.9.2 137 | - pyrsistent=0.17.3 138 | - pysocks=1.7.1 139 | - python=3.8.8 140 | - python-dateutil=2.8.1 141 | - pytz=2021.1 142 | - pyzmq=20.0.0 143 | - qt=5.9.7 144 | - qtconsole=5.0.3 145 | - qtpy=1.9.0 146 | - readline=8.1 147 | - requests=2.25.1 148 | - requests-oauthlib=1.3.0 149 | - rsa=4.7.2 150 | - scikit-learn=0.24.1 151 | - scipy=1.6.2 152 | - send2trash=1.5.0 153 | - setuptools=52.0.0 154 | - sip=4.19.13 155 | - six=1.15.0 156 | - sqlite=3.35.4 157 | - tensorboard=2.4.0 158 | - tensorboard-plugin-wit=1.6.0 159 | - tensorflow=2.3.0 160 | - tensorflow-base=2.3.0 161 | - tensorflow-datasets=1.2.0 162 | - tensorflow-estimator=2.3.0 163 | - tensorflow-metadata=0.14.0 164 | - tensorflow-probability=0.7 165 | - termcolor=1.1.0 166 | - terminado=0.9.4 167 | - testpath=0.4.4 168 | - threadpoolctl=2.1.0 169 | - tk=8.6.10 170 | - tornado=6.1 171 | - tqdm=4.59.0 172 | - traitlets=5.0.5 173 | - typing-extensions=3.7.4.3 174 | - typing_extensions=3.7.4.3 175 | - urllib3=1.26.4 176 | - wcwidth=0.2.5 177 | - webencodings=0.5.1 178 | - werkzeug=1.0.1 179 | - wheel=0.36.2 180 | - widgetsnbextension=3.5.1 181 | - wrapt=1.12.1 182 | - xlrd=2.0.1 183 | - xz=5.2.5 184 | - yarl=1.6.3 185 | - zeromq=4.3.4 186 | - zipp=3.4.1 187 | - zlib=1.2.11 188 | - zstd=1.4.9 189 | - pip: 190 | - amply==0.1.4 191 | - appdirs==1.4.4 192 | - bctpy==0.5.2 193 | - configargparse==1.4 194 | - datrie==0.8.2 195 | - docutils==0.17.1 196 | - filelock==3.0.12 197 | - gitdb==4.0.7 198 | - gitpython==3.1.14 199 | - nibabel==3.2.1 200 | - nilearn==0.8.0 201 | - ortools==9.0.9048 202 | - protobuf==3.17.3 203 | - pulp==2.4 204 | - pyyaml==5.4.1 205 | - ratelimiter==1.2.0.post0 206 | - smart-open==5.0.0 207 | - smmap==4.0.0 208 | - snakemake==6.3.0 209 | - stopit==1.1.2 210 | - toposort==1.6 211 | prefix: /home/vsip/soft/miniconda2/envs/py38 212 | 213 | -------------------------------------------------------------------------------- /plotutils.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | import matplotlib 4 | import matplotlib.pyplot as plt 5 | from matplotlib import lines 6 | import numpy as np 7 | 8 | 9 | def _get_labels(style): 10 | labels = "abcdefghijklmnopqrstuvwxyz" 11 | if style == "lowercase": 12 | return labels 13 | elif style == "uppercase": 14 | return labels.upper() 15 | else: 16 | raise ValueError(f"Unknown style '{style}'") 17 | 18 | 19 | class Background(): 20 | """ 21 | Background axes for Matplotlib figures, which can be used for layouting (when visible=True) 22 | and for plotting various annotations and markers that do not belong to any specific subplot. 23 | """ 24 | 25 | 26 | def __init__(self, fig=None, visible=False, spacing=0.1, linecolor='0.5', linewidth=1): 27 | """ 28 | Args: 29 | fig: Matplotlib figure. If None, the current one is used. 30 | visible (bool): Show the grid if True. 31 | spacing: Spacing of the background grid. Irrelevant if visible=False. 32 | linecolor: Default color of added lines. 33 | linewidth: Default width of added lines. 34 | """ 35 | 36 | if fig is not None: 37 | plt.scf(fig) 38 | ax = plt.axes([0,0,1,1], facecolor=None, zorder=-1000) 39 | plt.xticks(np.arange(0, 1 + spacing/2., spacing)) 40 | plt.yticks(np.arange(0, 1 + spacing/2., spacing)) 41 | plt.grid() 42 | plt.xlim(0, 1) 43 | plt.ylim(0, 1) 44 | ax.autoscale(False) 45 | if not visible: 46 | plt.axis('off') 47 | self.axes = ax 48 | self.linecolor = linecolor 49 | self.linewidth = linewidth 50 | 51 | def vline(self, x, y0=0, y1=1, **args): 52 | "Place a vertical line at position x spanning between y0 and y1." 53 | 54 | defargs = dict(color=self.linecolor, linewidth=self.linewidth) 55 | defargs.update(args) 56 | self.axes.add_line(lines.Line2D([x, x], [y0, y1], **defargs)) 57 | 58 | def hline(self, y, x0=0, x1=1, **args): 59 | "Place a horizontal line at position y spanning between x0 and x1." 60 | 61 | defargs = dict(color=self.linecolor, linewidth=self.linewidth) 62 | defargs.update(args) 63 | self.axes.add_line(lines.Line2D([x0, x1], [y, y], **defargs)) 64 | 65 | def box(self, pos, title=None, titlestyle=None, pad=0.0, **args): 66 | """Draw a box with optional title. 67 | 68 | Args: 69 | pos: (left, right, bottom, top) axes coordinates. 70 | title: Optional box title. 71 | titlestyle: Dict with arguments passed to plt.text(). 72 | pad: Padding size in axes coordinates. 73 | """ 74 | 75 | plt.sca(self.axes) 76 | width = pos[1] - pos[0] 77 | height = pos[3] - pos[2] 78 | 79 | defargs = dict(ec=self.linecolor, linewidth=self.linewidth, fc='none') 80 | defargs.update(args) 81 | 82 | fancy = matplotlib.patches.FancyBboxPatch((pos[0], pos[2]), width, height, 83 | boxstyle=f"round,pad={pad}", **defargs) 84 | self.axes.add_patch(fancy) 85 | 86 | if title: 87 | titleargs = dict(ha='left', va='center', backgroundcolor='w', 88 | color=self.linecolor, fontsize=12) 89 | if titlestyle is not None: 90 | titleargs.update(titlestyle) 91 | 92 | plt.text(pos[0]+0.02, pos[3]+pad, title, **titleargs) 93 | 94 | 95 | def add_labels(self, xs, ys, fontsize=18, style="uppercase", labels=None): 96 | """Place panel labels at positions given by x-coordinates and y-coordinates. 97 | 98 | Args: 99 | xs: x-coordinates of lower left corner of the labels. 100 | ys: y-coordinates of lower left corner of the labels. 101 | fontsize: Font size. 102 | style: Either 'uppercase' or 'lowercase'. 103 | labels: If provided, these labels are used. Overrides style. 104 | """ 105 | 106 | if labels is None: 107 | labels = _get_labels(style) 108 | 109 | assert len(xs) == len(ys) 110 | for x, y, label in zip(xs, ys, labels): 111 | self.axes.text(x, y, label, transform=self.axes.transAxes, size=fontsize, 112 | weight='bold', ha='left', va='bottom') 113 | 114 | 115 | def add_panel_labels(fig=None, axes=None, fontsize=18, xs=-0.05, ys=1.05, style='uppercase', labels=None): 116 | """Place panel labels to given (or all) axes, relative to their upper left corner. 117 | 118 | Args: 119 | fig: Matplotlib figure. If None, the current one is used. 120 | axes: Axes to which place the labels. If None, all axes in fig are used. 121 | fontsize: Font size. 122 | xs: x-coordinate(s) of the label lower left corners. In axes coordinates. 123 | Can be either float (then used for all axes), or list of floats, one for every axes. 124 | ys: y-coordinate(s) of the label lower left corners. In axes coordinates. 125 | Can be either float (then used for all axes), or list of floats, one for every axes. 126 | style: Either 'uppercase' or 'lowercase'. 127 | labels: If provided, these labels are used. Overrides style. 128 | """ 129 | 130 | if labels is None: 131 | labels = _get_labels(style) 132 | 133 | if fig is None: 134 | fig = plt.gcf() 135 | 136 | if axes is None: 137 | axes = fig.get_axes() 138 | 139 | if not hasattr(xs, '__iter__'): 140 | xs = itertools.repeat(xs) 141 | if not hasattr(ys, '__iter__'): 142 | ys = itertools.repeat(ys) 143 | 144 | for i, (ax, x, y) in enumerate(zip(axes, xs, ys)): 145 | ax.text(x, y, labels[i], transform=ax.transAxes, size=fontsize, 146 | weight='bold', ha='left', va='bottom') 147 | 148 | 149 | def axtext(ax, text, **args): 150 | """Place text in the middle of given axes, and remove everything else. 151 | 152 | Occasionaly useful for placing labels of subplot grids. 153 | """ 154 | 155 | defargs = {'fontsize': 12, 'ha': 'center', 'va': 'center'} 156 | defargs.update(args) 157 | plt.sca(ax) 158 | plt.text(0.5, 0.5, text, **defargs) 159 | plt.xlim([0, 1]); plt.ylim([0, 1]) 160 | plt.axis('off') 161 | 162 | 163 | def bottomleft_spines(ax): 164 | """Hide the right and top spines.""" 165 | 166 | ax.spines['top'].set_visible(False) 167 | ax.spines['right'].set_visible(False) 168 | ax.get_xaxis().tick_bottom() 169 | ax.get_yaxis().tick_left() 170 | --------------------------------------------------------------------------------