{"id":2769,"date":"2026-04-02T07:00:09","date_gmt":"2026-04-02T12:00:09","guid":{"rendered":"https:\/\/wollen.org\/blog\/?p=2769"},"modified":"2026-04-01T04:56:50","modified_gmt":"2026-04-01T09:56:50","slug":"easter-the-first-sunday-after","status":"publish","type":"post","link":"https:\/\/wollen.org\/blog\/2026\/04\/easter-the-first-sunday-after\/","title":{"rendered":"The first Sunday after the first full moon after&#8230;"},"content":{"rendered":"<p>I want to follow up on a <a href=\"https:\/\/wollen.org\/blog\/2026\/05\/celebrating-five-years-knock-on-wood\/\">previous post<\/a> that used text as a scatter marker. It employed the <code>TextToPath<\/code> method to (as you might guess) convert text to Path, which is a Matplotlib class similar to an SVG.<\/p>\n<p>This post will demonstrate how to convert <em>any<\/em> SVG to a Path. It will open the door to an essentially infinite number of marker choices. Rather than being restricted to circles, squares, or even text, you&#8217;ll be able to bring up Google Images and pick any SVG image you like.<\/p>\n<hr \/>\n<h4>1. Prepare the data.<\/h4>\n<p>Since Easter is this weekend I thought it would be fun to look at Easter Sunday dates over the next few years. The exact date varies quite a bit. It seems to be one of those things we accept without thinking too much about it.<\/p>\n<p>I couldn&#8217;t tell you how the date is calculated without looking it up, but I have a text file containing every Easter date through the end of this century. It&#8217;s linked at the bottom of this post.<\/p>\n<p>Read the file into a pandas DataFrame and take a look.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import pandas as pd\r\n\r\ndf = pd.read_csv(\"easter_dates.csv\", parse_dates=[\"date\"])\r\n\r\nprint(df.head())<\/pre>\n<p>The output:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">        date\r\n0 2026-04-05\r\n1 2027-03-28\r\n2 2028-04-16\r\n3 2029-04-01\r\n4 2030-04-21<\/pre>\n<p>The plan is to make a scatter plot with time variables on both axes:<\/p>\n<ul>\n<li>x-axis \u2014 Year<\/li>\n<li>y-axis \u2014 Month\/Day<\/li>\n<\/ul>\n<p>Let&#8217;s first create an integer year column for the x-axis. We can efficiently operate on datetime types by using the <code>dt<\/code> accessor. This approach skips writing an <code>apply<\/code> function, which is always slower.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">df['x_year'] = df['date'].dt.strftime(\"%Y\").astype(int)<\/pre>\n<p>The y-axis is tricky because we want to maintain a datetime type, but we need every date to be within the same year. The exact year doesn&#8217;t matter because it won&#8217;t be displayed on the plot.<\/p>\n<p>Use <code>strftime<\/code> again but this time force all month-day pairs to exist in the year 2000.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">df['y_date'] = pd.to_datetime(df['date'].dt.strftime(\"2000-%m-%d\"))<\/pre>\n<p>Filter the DataFrame down to the next 20 years and take a look.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">df = df[df['x_year'] &lt;= 2045]\r\n\r\nprint(df.head())<\/pre>\n<p>The output:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">        date  x_year     y_date\r\n0 2026-04-05    2026 2000-04-05\r\n1 2027-03-28    2027 2000-03-28\r\n2 2028-04-16    2028 2000-04-16\r\n3 2029-04-01    2029 2000-04-01\r\n4 2030-04-21    2030 2000-04-21<\/pre>\n<p>Our x and y variables are ready to go. We can move on to making a scatter plot with custom markers.<\/p>\n<hr \/>\n<h4>2. Plot the data.<\/h4>\n<p>The plot will use a custom &#8220;wollen_easter&#8221; Matplotlib style that will be linked at the bottom of this post. Fair warning: there&#8217;s a lot of pink!<\/p>\n<p>Create a Figure and an Axes instance for plotting.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import matplotlib.pyplot as plt\r\n\r\nplt.style.use(\"wollen_easter.mplstyle\")\r\n\r\nfig, ax = plt.subplots()<\/pre>\n<p>I found this Easter egg SVG that will work as a marker. It&#8217;s festive and has enough detail to show off Paths.<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_egg.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2774\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_egg.png\" alt=\"\" width=\"250\" height=\"250\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_egg.png 250w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_egg-150x150.png 150w\" sizes=\"auto, (max-width: 250px) 100vw, 250px\" \/><\/a><\/p>\n<p>We&#8217;ll use the <a href=\"https:\/\/github.com\/nvictus\/svgpath2mpl\" target=\"_blank\" rel=\"noopener\">svgpath2mpl<\/a> package to convert SVG markup into <em>vertices<\/em> and <em>path codes<\/em> that Matplotlib can understand. In case you&#8217;ve never noticed, you can right-click any SVG file and open it in a text editor. You won&#8217;t find anything resembling pixels, just a series of instructions for the computer to draw various shapes. That&#8217;s essentially the same as a Matplotlib Path.<\/p>\n<p>Use a <a href=\"https:\/\/www.w3schools.com\/python\/python_regex.asp\" target=\"_blank\" rel=\"noopener\">regex<\/a> search to isolate the important information. Then call <code>svgpath2mpl.parse_path<\/code> on each block. Our Easter egg has several unconnected shapes (dots and stripes) so they exist as separate blocks within the markup.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from svgpath2mpl import parse_path\r\nimport re\r\n\r\nwith open(\"egg.svg\", \"r\") as f:\r\n    text = f.read()\r\n\r\npath_parts = [parse_path(item) for item in re.findall(r' d=\"(.*?)\"', text, re.DOTALL)]<\/pre>\n<p>But at this point we have several Matplotlib Paths, each representing one tiny piece of the Easter egg.<\/p>\n<p>To combine them we need to separate vertices from codes, which both exist as NumPy arrays, and then <code>concatenate<\/code> the arrays together.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import numpy as np\r\n\r\nvertices_combined = np.concatenate([part.vertices for part in path_parts])\r\ncodes_combined = np.concatenate([part.codes for part in path_parts])<\/pre>\n<p>Pass repackaged vertices and codes to <code>matplotlib.path.Path<\/code>.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from matplotlib.path import Path\r\n\r\nmarker_path = Path(vertices_combined, codes_combined)<\/pre>\n<p>Now we have a single Matplotlib Path that contains <em>all<\/em> of the Easter egg.<\/p>\n<p>But we&#8217;re not quite done. A quirk in the SVG format means that we never know if a Path will be rotated or mirrored. In this case, we need to flip it over the horizontal axis. (1, -1) means no change to the horizontal, mirror the vertical.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import matplotlib.transforms as mpltf\r\n\r\nmarker_path = marker_path.transformed(mpltf.Affine2D().scale(1, -1))<\/pre>\n<p>Matplotlib makes no attempt to center Paths so we have to do that manually as well. We can translate it, i.e. slide it left\/right\/up\/down, until it looks centered.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">marker_path = marker_path.transformed(mpltf.Affine2D().translate(-258, 265))<\/pre>\n<figure id=\"attachment_2781\" aria-describedby=\"caption-attachment-2781\" style=\"width: 900px\" class=\"wp-caption aligncenter\"><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/transform_diagram.png\"><img loading=\"lazy\" decoding=\"async\" class=\"wp-image-2781 size-full\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/transform_diagram.png\" alt=\"\" width=\"900\" height=\"330\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/transform_diagram.png 900w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/transform_diagram-300x110.png 300w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/transform_diagram-768x282.png 768w\" sizes=\"auto, (max-width: 900px) 100vw, 900px\" \/><\/a><figcaption id=\"caption-attachment-2781\" class=\"wp-caption-text\">This figure demonstrates how transforms affect a Path. All three markers represent the point (0, 0).<\/figcaption><\/figure>\n<p>It takes a bit of fine-tuning but once a Path is created it&#8217;s super versatile within Matplotlib. For example, you could use a Path to clip the edge of a Rectangle or Circle patch. And like SVGs, no information is lost when Paths are scaled up or down.<\/p>\n<hr style=\"margin-left: 15%; margin-right: 15%;\" \/>\n<p>With custom markers in hand, let&#8217;s get back to plotting Easter dates.<\/p>\n<p>I collected the Path code in a <code>get_marker_path<\/code> function to keep things organized.<\/p>\n<p>All markers on the scatter plot will be Easter eggs, but they don&#8217;t have to be the same color. I made a list of five Easter colors to alternate through. <code>color_list<\/code> needs to be the same length as data columns so scale it using <code>df.shape<\/code>.<\/p>\n<p>Specify <code>zorder=2<\/code> because we&#8217;ll do some background shading with <code>fill_between<\/code> in a moment.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">egg_marker = get_marker_path()\r\n\r\ncolor_list = [\"#367D83\", \"#8ADBD2\", \"#F47A97\", \"#E9C05F\", \"#C079F8\"]\r\n\r\nax.scatter(x=df['x_year'],\r\n           y=df['y_date'],\r\n           marker=egg_marker,\r\n           s=400,\r\n           linewidth=0,\r\n           color=(color_list * 100)[:df.shape[0]],\r\n           zorder=2)<\/pre>\n<p>Easter Sunday can be any date from March 22nd to April 25th. Use <code>pd.date_range<\/code> to generate a list of dates to serve as y-ticks. &#8220;7D&#8221; means ticks will be located seven days apart.<\/p>\n<p>All y values exist in the year 2000 but we obviously don&#8217;t want that visible on the plot. Use <code>strftime<\/code> to generate a list of <code>yticklabels<\/code> that include only month and day.<\/p>\n<p>Year values along the x-axis are integers so they&#8217;re more straightforward. Just rotate labels 60 degrees to avoid crowding them together.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">y_ticks = pd.date_range(start=pd.Timestamp(\"March 22, 2000\"),\r\n                        end=pd.Timestamp(\"April 26, 2000\"),\r\n                        freq=\"7D\")\r\n\r\nax.set(yticks=y_ticks,\r\n       yticklabels=[date.strftime(\"%B %-d\") for date in y_ticks],\r\n       ylim=(pd.Timestamp(\"March 20, 2000\"), pd.Timestamp(\"April 28, 2000\")),\r\n       xticks=range(2026, 2046),\r\n       xlim=(2025.5, 2045.5))\r\n\r\nplt.setp(ax.xaxis.get_majorticklabels(), rotation=60, ha=\"right\", rotation_mode=\"anchor\")<\/pre>\n<p>The Figure&#8217;s <em>facecolor<\/em>, i.e. the outer margins of the plot, are pink. Let&#8217;s do a yellow background for the Axes.<\/p>\n<p>This loop steps through x-axis year values and creates vertical bars of alternating yellow shades. There isn&#8217;t a ton of data communicated on the plot\u2014just 20 scatter markers\u2014so it helps to break up the solid background.<\/p>\n<p>A bool named <code>toggle<\/code> switches between True and False on each iteration of the loop. The fill color depends on its value.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">toggle = True\r\n\r\nfor year in range(2000, 2100):\r\n    ax.fill_between(x=[year, year + 1],\r\n                    y1=[pd.Timestamp(\"January 1, 2000\"), pd.Timestamp(\"January 1, 2000\")],\r\n                    y2=[pd.Timestamp(\"December 31, 2000\"), pd.Timestamp(\"December 31, 2000\")],\r\n                    color={True: \"#F3EB9A\", False: \"#D3DB8A\"}[toggle],\r\n                    alpha=0.13,\r\n                    zorder=1)\r\n\r\n    toggle = not toggle<\/pre>\n<p>When I want to include a title <strong>and<\/strong> a subtitle, it usually works best to place them in the upper-left corner. Matplotlib has no built-in method for subtitles but we can call <code>text<\/code> a couple times. Just give the subtitle a slightly smaller font size.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">ax.text(x=2025.5,\r\n        y=pd.Timestamp(\"April 30, 2000 at 3 AM\"),\r\n        s=\"Easter Dates  \u2022  2026\u20132045\",\r\n        size=9)\r\n\r\nax.text(x=2025.5,\r\n        y=pd.Timestamp(\"April 28, 2000 at 6 PM\"),\r\n        s=\"Easter Sunday is the first Sunday following the first full moon after the spring equinox.\",\r\n        size=8)<\/pre>\n<p>Finally, save the Figure. A 200 <code>dpi<\/code> makes the Easter egg details easier to see.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">plt.savefig(\"easter_dates.png\", dpi=200)<\/pre>\n<hr \/>\n<h4>3. The output.<\/h4>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_dates-1.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter wp-image-3321 size-full\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_dates-1.png\" alt=\"\" width=\"2200\" height=\"1400\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_dates-1.png 2200w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_dates-1-300x191.png 300w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_dates-1-1024x652.png 1024w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_dates-1-768x489.png 768w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_dates-1-1536x977.png 1536w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2027\/03\/easter_dates-1-2048x1303.png 2048w\" sizes=\"auto, (max-width: 2200px) 100vw, 2200px\" \/><\/a><\/p>\n<p>Like I said, pink and yellow aren&#8217;t my comfort zone. But I think it looks good! I also generally hate scripty fonts but I can make an exception for holidays.<\/p>\n<p>This year&#8217;s April 5<sup>th<\/sup> Easter Sunday is a middle-of-the-road date. Not too early, not too late. Next year we&#8217;ll be celebrating in March.<!-- HFCM by 99 Robots - Snippet # 15: endmark-python -->\n<span class=\"endmark-python\"><\/span>\n<!-- \/end HFCM by 99 Robots -->\n<\/p>\n<hr \/>\n<p><a href=\"https:\/\/wollen.org\/misc\/easter_dates_2026.zip\"><strong>Download the data.<\/strong><\/a><\/p>\n<p><strong>Full code:<\/strong><\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import pandas as pd\r\nimport matplotlib.pyplot as plt\r\nfrom matplotlib.path import Path\r\nimport matplotlib.transforms as mpltf\r\nfrom svgpath2mpl import parse_path\r\nimport numpy as np\r\nimport re\r\n\r\n\r\ndef get_marker_path():\r\n    with open(\"egg.svg\", \"r\") as f:\r\n        text = f.read()\r\n    path_parts = [parse_path(item) for item in re.findall(r' d=\"(.*?)\"', text, re.DOTALL)]\r\n    vertices_combined = np.concatenate([part.vertices for part in path_parts])\r\n    codes_combined = np.concatenate([part.codes for part in path_parts])\r\n    marker_path = Path(vertices_combined, codes_combined)\r\n    marker_path = marker_path.transformed(mpltf.Affine2D().scale(1, -1))\r\n    marker_path = marker_path.transformed(mpltf.Affine2D().translate(-258, 265))\r\n    return marker_path\r\n\r\n\r\ndf = pd.read_csv(\"easter_dates.csv\", parse_dates=[\"date\"])\r\n\r\ndf['x_year'] = df['date'].dt.strftime(\"%Y\").astype(int)\r\n\r\ndf['y_date'] = pd.to_datetime(df['date'].dt.strftime(\"2000-%m-%d\"))\r\n\r\ndf = df[df['x_year'] &lt;= 2045]\r\n\r\nplt.style.use(\"wollen_easter.mplstyle\")\r\n\r\nfig, ax = plt.subplots()\r\n\r\negg_marker = get_marker_path()\r\n\r\ncolor_list = [\"#367D83\", \"#8ADBD2\", \"#F47A97\", \"#E9C05F\", \"#C079F8\"]\r\n\r\nax.scatter(x=df['x_year'],\r\n           y=df['y_date'],\r\n           marker=egg_marker,\r\n           s=400,\r\n           linewidth=0,\r\n           color=(color_list * 100)[:df.shape[0]],\r\n           zorder=2)\r\n\r\ny_ticks = pd.date_range(start=pd.Timestamp(\"March 22, 2000\"),\r\n                        end=pd.Timestamp(\"April 26, 2000\"),\r\n                        freq=\"7D\")\r\n\r\nax.set(yticks=y_ticks,\r\n       yticklabels=[date.strftime(\"%B %-d\") for date in y_ticks],\r\n       ylim=(pd.Timestamp(\"March 20, 2000\"), pd.Timestamp(\"April 28, 2000\")),\r\n       xticks=range(2026, 2046),\r\n       xlim=(2025.5, 2045.5))\r\n\r\nplt.setp(ax.xaxis.get_majorticklabels(), rotation=60, ha=\"right\", rotation_mode=\"anchor\")\r\n\r\ntoggle = True\r\n\r\nfor year in range(2000, 2100):\r\n    ax.fill_between(x=[year, year + 1],\r\n                    y1=[pd.Timestamp(\"January 1, 2000\"), pd.Timestamp(\"January 1, 2000\")],\r\n                    y2=[pd.Timestamp(\"December 31, 2000\"), pd.Timestamp(\"December 31, 2000\")],\r\n                    color={True: \"#F3EB9A\", False: \"#D3DB8A\"}[toggle],\r\n                    alpha=0.13,\r\n                    zorder=1)\r\n\r\n    toggle = not toggle\r\n\r\nax.text(x=2025.5,\r\n        y=pd.Timestamp(\"April 30, 2000 at 3 AM\"),\r\n        s=\"Easter Dates  \u2022  2026\u20132045\",\r\n        size=9)\r\n\r\nax.text(x=2025.5,\r\n        y=pd.Timestamp(\"April 28, 2000 at 6 PM\"),\r\n        s=\"Easter Sunday is the first Sunday following the first full moon after the spring equinox.\",\r\n        size=8)\r\n\r\nplt.savefig(\"easter_dates.png\", dpi=200)<\/pre>\n<p>&nbsp;<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I want to follow up on a previous post that used text as a scatter marker. It employed the TextToPath method to (as you might guess) convert text to Path, which is a Matplotlib class similar to an SVG. This<\/p>\n","protected":false},"author":1,"featured_media":2806,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[634],"tags":[620,583,611,584,566,617,616,128,397,610,183,622,619,559,615,24,607,618,604,75,30,602,562,613,25,606,621,133,565,603,608,609,605,614,612,582],"class_list":["post-2769","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-holiday","tag-april","tag-codes","tag-concatenate","tag-custom","tag-custom-marker","tag-dates","tag-easter","tag-equinox","tag-fill_between","tag-flip","tag-holiday","tag-lent","tag-march","tag-marker","tag-markup","tag-matplotlib","tag-mirror","tag-moon","tag-mpltf","tag-numpy","tag-pandas","tag-parse_path","tag-path","tag-path-codes","tag-python","tag-rotate","tag-season","tag-spring","tag-svg","tag-svgpath2mpl","tag-transform","tag-transformed","tag-translate","tag-vertex","tag-vertices","tag-verts"],"_links":{"self":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2769","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/comments?post=2769"}],"version-history":[{"count":22,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2769\/revisions"}],"predecessor-version":[{"id":3382,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2769\/revisions\/3382"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media\/2806"}],"wp:attachment":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media?parent=2769"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/categories?post=2769"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/tags?post=2769"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}