├── requirements.txt ├── calendar-of-life.png ├── calendar-of-life-dark.png ├── README.md ├── LICENSE ├── .gitignore └── calendar_of_life.py /requirements.txt: -------------------------------------------------------------------------------- 1 | apng 2 | imageio 3 | matplotlib 4 | numpy 5 | pygifsicle 6 | -------------------------------------------------------------------------------- /calendar-of-life.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basnijholt/calendar-of-life/main/calendar-of-life.png -------------------------------------------------------------------------------- /calendar-of-life-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basnijholt/calendar-of-life/main/calendar-of-life-dark.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # calendar-of-life 2 | Inspired by [Kurzgesagt](https://www.youtube.com/watch?v=JXeJANDKwDc) and [Tim Urban](https://waitbutwhy.com/2014/05/life-weeks.html). 3 | 4 | Create a calendar of your life: 5 | 6 | light mode: 7 | ![](calendar-of-life.png) 8 | 9 | dark mode: 10 | ![](calendar-of-life-dark.png) 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bas Nijholt 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /calendar_of_life.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import bisect 4 | import os 5 | from collections import defaultdict 6 | from datetime import date, timedelta 7 | from typing import NamedTuple, Optional 8 | 9 | import imageio 10 | import matplotlib.pyplot as plt 11 | import numpy as np 12 | from apng import APNG 13 | from pygifsicle import optimize 14 | 15 | 16 | def create_calendar( 17 | life: list[LifeStage], 18 | dark_mode: bool = True, 19 | fname: str = "calendar-of-life-dark.png", 20 | current_week_alpha: float | None = None, 21 | show: bool = True, 22 | ): 23 | if dark_mode: 24 | plt.style.use("dark_background") 25 | face = "k" 26 | edge = "w" 27 | else: 28 | plt.rcParams.update(plt.rcParamsDefault) 29 | face = "w" 30 | edge = "k" 31 | 32 | h, w = 13, 9 33 | fig, ax = plt.subplots(figsize=(w, h)) 34 | ax.set_axis_off() 35 | fig.suptitle("Calendar of life", y=0.89) 36 | # Correct for the fact that 52 weeks is not exactly 1 year 37 | # so pretend like a week is 7.024 days. 38 | days_per_week = 365.25 / 52 39 | weeks_of_life = [ 40 | (b.date - a.date).days / days_per_week for a, b in zip(life, life[1:]) 41 | ] 42 | weeks_of_life_past = np.cumsum(weeks_of_life) 43 | 44 | data = defaultdict(list) 45 | colors = {e.stage: e.color for e in life[1:]} 46 | colors["future"] = face 47 | week_num = 0 48 | weeks = np.linspace(0, h, 52) 49 | years = np.linspace(w, 0, 80) 50 | for year in years: 51 | for week in weeks: 52 | week_num += 1 53 | index = bisect.bisect_left(weeks_of_life_past, week_num) + 1 54 | stage = ( 55 | "future" if index == len(weeks_of_life_past) + 1 else life[index].stage 56 | ) 57 | data[stage].append((week, year)) 58 | 59 | for k, v in data.items(): 60 | ax.scatter(*zip(*v), edgecolors=edge, facecolor=colors[k], label=k) 61 | 62 | if current_week_alpha is not None: 63 | current_week = data["future"].pop(0) 64 | ax.scatter( 65 | *current_week, 66 | edgecolors=edge, 67 | facecolor=edge, 68 | label="now", 69 | alpha=current_week_alpha, 70 | ) 71 | 72 | for i, year in enumerate(years): 73 | if i % 10 == 0 and i > 0: 74 | ax.text( 75 | -0.2, 76 | year, 77 | f"{i}y", 78 | horizontalalignment="right", 79 | verticalalignment="center", 80 | fontsize=9, 81 | ) 82 | 83 | plt.legend() 84 | plt.savefig(fname, dpi=300) 85 | if show: 86 | plt.show() 87 | 88 | 89 | def animate( 90 | life: list[LifeStage], 91 | dark_mode: bool = True, 92 | save_gif: bool = True, 93 | save_apng: bool = True, 94 | fname_stem: str = "calendar-of-life-dark-animated", 95 | ): 96 | # Create animation 97 | alphas = np.linspace(0, 1, 6) 98 | fnames = [] 99 | for alpha in alphas: 100 | fname = f"alpha-{alpha}.png" 101 | create_calendar( 102 | life, dark_mode=dark_mode, current_week_alpha=alpha, fname=fname, show=False 103 | ) 104 | fnames.append(fname) 105 | fnames += fnames[::-1] 106 | 107 | if save_apng: 108 | # Save animated png 109 | im = APNG() 110 | for fname in fnames: 111 | im.append_file(fname, delay=50) 112 | im.save(f"{fname_stem}.png") 113 | 114 | if save_gif: 115 | # Save gif 116 | images = [imageio.imread(fname) for fname in fnames] 117 | imageio.mimsave(f"{fname_stem}.gif", images) 118 | optimize(f"{fname_stem}.gif") 119 | 120 | # Cleanup 121 | for fname in fnames[: len(alphas)]: 122 | os.unlink(fname) 123 | 124 | 125 | def create_all(life: list[tuple[str, date, str | None]]): 126 | create_calendar(life, dark_mode=True, fname="calendar-of-life-dark.png", show=False) 127 | create_calendar(life, dark_mode=False, fname="calendar-of-life.png", show=False) 128 | animate(life, dark_mode=True, fname_stem="calendar-of-life-dark-animated") 129 | animate(life, dark_mode=False, fname_stem="calendar-of-life-animated") 130 | 131 | 132 | class LifeStage(NamedTuple): 133 | stage: str 134 | date: date 135 | color: str | None = None 136 | 137 | 138 | if __name__ == "__main__": 139 | birthday = date(1990, 12, 28) 140 | life = [ # (stage, date, color) 141 | LifeStage("born", birthday), 142 | LifeStage("early childhood", birthday + timedelta(days=4 * 365), "C0"), 143 | LifeStage("school", date(2003, 8, 18), "C1"), 144 | LifeStage("high school", date(2009, 9, 1), "C2"), 145 | LifeStage("university", date(2015, 8, 1), "C3"), 146 | LifeStage("travel", date(2016, 2, 1), "C8"), 147 | LifeStage("phd", date(2020, 2, 1), "C6"), 148 | LifeStage("work", date.today(), "C4"), 149 | ] 150 | create_all(life) 151 | --------------------------------------------------------------------------------