Skip to content

Commit b4191bd

Browse files
feat(pygal): implement venn-labeled-items (#5381)
## Implementation: `venn-labeled-items` - python/pygal Implements the **python/pygal** version of `venn-labeled-items`. **File:** `plots/venn-labeled-items/implementations/python/pygal.py` **Parent Issue:** #5364 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/24923831951)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 8ed0981 commit b4191bd

2 files changed

Lines changed: 480 additions & 0 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
""" anyplot.ai
2+
venn-labeled-items: Chartgeist-Style Venn Diagram with Labeled Items
3+
Library: pygal 3.1.0 | Python 3.14.4
4+
Quality: 86/100 | Created: 2026-04-25
5+
"""
6+
7+
import importlib
8+
import math
9+
import os
10+
import re
11+
import sys
12+
from collections import defaultdict
13+
14+
15+
# Drop the script directory from sys.path so the `pygal` package resolves, not this file
16+
sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != os.path.dirname(os.path.abspath(__file__))]
17+
pygal = importlib.import_module("pygal")
18+
Style = importlib.import_module("pygal.style").Style
19+
cairosvg = importlib.import_module("cairosvg")
20+
21+
22+
# Theme tokens
23+
THEME = os.getenv("ANYPLOT_THEME", "light")
24+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
25+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
26+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
27+
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
28+
29+
# Okabe-Ito categorical palette: brand green, vermillion, blue
30+
COLOR_A = "#009E73"
31+
COLOR_B = "#D55E00"
32+
COLOR_C = "#0072B2"
33+
34+
# Symmetric three-circle Venn: equilateral triangle of centers (apex up)
35+
RADIUS = 1.0
36+
OFFSET = RADIUS / math.sqrt(3)
37+
ax, ay = -OFFSET * math.sin(math.radians(60)), -OFFSET * math.cos(math.radians(60))
38+
bx, by = OFFSET * math.sin(math.radians(60)), -OFFSET * math.cos(math.radians(60))
39+
cx, cy = 0.0, OFFSET
40+
41+
circles = [
42+
{"name": "OVERHYPED", "color": COLOR_A, "center": (ax, ay), "label_xy": (ax - 0.95, ay - 1.10), "anchor": "start"},
43+
{
44+
"name": "ACTUALLY USEFUL",
45+
"color": COLOR_B,
46+
"center": (bx, by),
47+
"label_xy": (bx + 0.95, by - 1.10),
48+
"anchor": "end",
49+
},
50+
{"name": "SECRETLY LOVED", "color": COLOR_C, "center": (cx, cy), "label_xy": (cx, cy + 1.18), "anchor": "middle"},
51+
]
52+
53+
# Items distributed across the seven interior zones
54+
items_raw = [
55+
("NFTs", "A"),
56+
("Metaverse", "A"),
57+
("Web3", "A"),
58+
("Google Maps", "B"),
59+
("Sticky Notes", "B"),
60+
("USB Hubs", "B"),
61+
("Karaoke", "C"),
62+
("Postcards", "C"),
63+
("Smartphones", "AB"),
64+
("Email", "AB"),
65+
("Crocs", "AC"),
66+
("Pumpkin Spice", "AC"),
67+
("Spotify", "BC"),
68+
("Dolly Parton", "BC"),
69+
("Sourdough", "ABC"),
70+
("TikTok", "ABC"),
71+
]
72+
73+
# Centroids of each Venn region in chart-data units
74+
zone_centers = {
75+
"A": (ax - 0.55, ay + 0.05),
76+
"B": (bx + 0.55, by + 0.05),
77+
"C": (cx, cy + 0.50),
78+
"AB": (0.0, by - 0.32),
79+
"AC": (-0.45, 0.20),
80+
"BC": (0.45, 0.20),
81+
"ABC": (0.0, -0.05),
82+
}
83+
84+
LINE_HEIGHT = 0.13
85+
zone_to_items = defaultdict(list)
86+
for label, zone in items_raw:
87+
zone_to_items[zone].append(label)
88+
89+
item_points = []
90+
for zone, labels in zone_to_items.items():
91+
zx, zy = zone_centers[zone]
92+
n = len(labels)
93+
start_y = zy + (n - 1) * LINE_HEIGHT / 2
94+
for i, label in enumerate(labels):
95+
item_points.append({"value": (zx, start_y - i * LINE_HEIGHT), "label": label})
96+
97+
98+
# Parametric points for a circle outline (closed polyline)
99+
def circle_outline(center, r, n=120):
100+
cx0, cy0 = center
101+
return [(cx0 + r * math.cos(2 * math.pi * i / n), cy0 + r * math.sin(2 * math.pi * i / n)) for i in range(n + 1)]
102+
103+
104+
# Style — derived from theme tokens
105+
custom_style = Style(
106+
background=PAGE_BG,
107+
plot_background=PAGE_BG,
108+
foreground=INK,
109+
foreground_strong=INK,
110+
foreground_subtle=INK_MUTED,
111+
colors=(COLOR_A, COLOR_B, COLOR_C, INK, INK, INK, INK),
112+
opacity="1",
113+
opacity_hover="1",
114+
stroke_width=6,
115+
stroke_opacity=".90",
116+
stroke_opacity_hover=".90",
117+
title_font_size=72,
118+
label_font_size=22,
119+
major_label_font_size=22,
120+
legend_font_size=22,
121+
value_font_size=42,
122+
value_label_font_size=42,
123+
title_font_family="serif",
124+
label_font_family="serif",
125+
major_label_font_family="serif",
126+
legend_font_family="serif",
127+
value_font_family="serif",
128+
value_label_font_family="serif",
129+
transition="0",
130+
)
131+
132+
# Plot — square 3600×3600 canvas suits the radial Venn layout
133+
chart = pygal.XY(
134+
width=3600,
135+
height=3600,
136+
style=custom_style,
137+
title="Pop Culture Vibes 2026 · venn-labeled-items · pygal · anyplot.ai",
138+
show_legend=False,
139+
show_x_labels=False,
140+
show_y_labels=False,
141+
show_x_guides=False,
142+
show_y_guides=False,
143+
show_minor_x_labels=False,
144+
show_minor_y_labels=False,
145+
xrange=(-2.30, 2.30),
146+
range=(-2.30, 2.30),
147+
margin=20,
148+
spacing=0,
149+
show_dots=True,
150+
dots_size=0,
151+
print_labels=True,
152+
print_values=False,
153+
pretty_print=True,
154+
)
155+
156+
# Three circle outlines (one series per circle) — fills are added via post-processing
157+
for c in circles:
158+
chart.add("", circle_outline(c["center"], RADIUS), stroke=True, fill=False, show_dots=False)
159+
160+
# Item names — text-only placement at zone centroids
161+
chart.add("Items", item_points, stroke=False, show_dots=True)
162+
163+
# Category names — same labeling mechanism, restyled by post-processor below
164+
for c in circles:
165+
chart.add("", [{"value": c["label_xy"], "label": c["name"]}], stroke=False, show_dots=True)
166+
167+
svg = chart.render().decode("utf-8")
168+
169+
170+
# Post-process — pygal cannot natively (a) fill a closed polyline or (b) per-label
171+
# typography. Both are added directly to the SVG output.
172+
def fill_circle_path(svg_text, serie_idx, color, opacity):
173+
pattern = re.compile(
174+
r'(<g class="series serie-' + str(serie_idx) + r' color-\d+">\s*<path[^>]*?)class="line reactive nofill"'
175+
)
176+
return pattern.sub(
177+
r'\1class="line reactive" style="fill:' + color + ";fill-opacity:" + str(opacity) + r';stroke-width:7"',
178+
svg_text,
179+
count=1,
180+
)
181+
182+
183+
for idx, c in enumerate(circles):
184+
svg = fill_circle_path(svg, idx, c["color"], 0.18)
185+
186+
187+
# Restyle category labels by matching their text content
188+
def restyle_label(svg_text, label_text, color, anchor, font_size):
189+
pattern = re.compile(r'<text(\s+x="[^"]*"\s+y="[^"]*")\s+class="label">' + re.escape(label_text) + r"</text>")
190+
return pattern.sub(
191+
r'<text\1 class="label" style="font-size:'
192+
+ str(font_size)
193+
+ ";font-style:italic;font-weight:bold;text-anchor:"
194+
+ anchor
195+
+ ";fill:"
196+
+ color
197+
+ r'">'
198+
+ label_text
199+
+ "</text>",
200+
svg_text,
201+
count=1,
202+
)
203+
204+
205+
for c in circles:
206+
svg = restyle_label(svg, c["name"], c["color"], c["anchor"], 64)
207+
208+
# Pygal auto-picks white text whenever a series color is dark — that turns
209+
# the item labels invisible on our cream/charcoal background. Rewrite those
210+
# rules in place so labels inherit the theme INK instead.
211+
svg = re.sub(r"(\.text-overlay \.color-\d+ text \{\s*fill:\s*)[^;}\s]+", r"\1" + INK, svg)
212+
# Bump the rendered label size to the 42px set in Style above; pygal's
213+
# CSS hard-codes 36px ignoring `value_label_font_size` for XY plots.
214+
svg = re.sub(r"(\.text-overlay text\.label \{[^}]*font-size:\s*)\d+px", r"\g<1>42px", svg)
215+
216+
# Editorial subtitle injected at a fixed canvas position, theme-aware
217+
subtitle = (
218+
'<g class="anyplot-subtitle"><text x="1800" y="3540" '
219+
'style="font-family:serif;font-style:italic;font-size:42px;fill:' + INK_SOFT + ';text-anchor:middle">'
220+
"A field guide to sixteen things, three feelings, and seven overlapping truths"
221+
"</text></g>"
222+
)
223+
svg = svg.replace("</svg>", subtitle + "</svg>")
224+
225+
226+
# Save — interactive SVG embedded in HTML, plus rasterized PNG via cairosvg
227+
with open(f"plot-{THEME}.svg", "w") as f:
228+
f.write(svg)
229+
230+
with open(f"plot-{THEME}.html", "w") as f:
231+
f.write("<!doctype html><html><body style='margin:0;background:" + PAGE_BG + "'>" + svg + "</body></html>")
232+
233+
cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to=f"plot-{THEME}.png", output_width=3600, output_height=3600)

0 commit comments

Comments
 (0)