Skip to content

Commit 633200b

Browse files
feat(plotnine): implement maze-circular (#7491)
## Implementation: `maze-circular` - python/plotnine Implements the **python/plotnine** version of `maze-circular`. **File:** `plots/maze-circular/implementations/python/plotnine.py` **Parent Issue:** #3804 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/26148447266)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 293a571 commit 633200b

2 files changed

Lines changed: 287 additions & 258 deletions

File tree

Lines changed: 137 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
maze-circular: Circular Maze Puzzle
3-
Library: plotnine 0.15.2 | Python 3.13.11
4-
Quality: 91/100 | Created: 2026-01-16
3+
Library: plotnine 0.15.4 | Python 3.13.13
4+
Quality: 91/100 | Updated: 2026-05-20
55
"""
66

7+
import os
8+
79
import numpy as np
810
import pandas as pd
911
from plotnine import (
1012
aes,
1113
coord_fixed,
12-
element_blank,
14+
element_rect,
1315
element_text,
1416
geom_point,
1517
geom_segment,
@@ -21,197 +23,187 @@
2123
)
2224

2325

24-
# Circular maze parameters
26+
# Theme tokens
27+
THEME = os.getenv("ANYPLOT_THEME", "light")
28+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
29+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
30+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
31+
GOAL_COLOR = "#009E73" # Okabe-Ito position 1
32+
33+
# Maze parameters
2534
np.random.seed(42)
26-
n_rings = 7
27-
sectors_per_ring = [1, 6, 12, 18, 24, 30, 36, 42] # Increasing sectors outward
2835
difficulty = "medium"
36+
n_rings = {"easy": 5, "medium": 7, "hard": 9}[difficulty]
37+
base_sectors = [1, 6, 12, 18, 24, 30, 36, 42, 48, 54]
38+
sectors_per_ring = base_sectors[: n_rings + 1]
2939
ring_width = 1.0
30-
31-
# Calculate radii for each ring
3240
radii = [i * ring_width for i in range(n_rings + 2)]
3341

34-
# Initialize maze structure
35-
# For each ring, track which radial walls exist and which arc walls exist
36-
# radial_walls[ring][sector] = True means wall exists between this sector and next
37-
# arc_walls[ring][sector] = True means wall exists on outer arc of this sector
38-
42+
# Initialize maze walls
3943
radial_walls = []
4044
arc_walls = []
41-
4245
for ring in range(n_rings + 1):
43-
n_sectors = sectors_per_ring[min(ring, len(sectors_per_ring) - 1)]
44-
radial_walls.append([True] * n_sectors)
45-
arc_walls.append([True] * n_sectors)
46-
47-
# Build maze using modified DFS for circular structure
48-
# Cell representation: (ring, sector)
49-
# We work from outside to inside
50-
51-
# Create adjacency and carve passages
52-
visited = set()
53-
parent = {}
54-
46+
n_sec = sectors_per_ring[min(ring, len(sectors_per_ring) - 1)]
47+
radial_walls.append([True] * n_sec)
48+
arc_walls.append([True] * n_sec)
5549

56-
# Map cells properly considering sector changes between rings
57-
def get_neighbors(ring, sector):
58-
"""Get neighboring cells with proper sector mapping between rings."""
59-
neighbors = []
60-
n_sectors_current = sectors_per_ring[min(ring, len(sectors_per_ring) - 1)]
61-
62-
# Same ring - adjacent sectors
63-
prev_sector = (sector - 1) % n_sectors_current
64-
next_sector = (sector + 1) % n_sectors_current
65-
neighbors.append((ring, prev_sector, "radial_prev"))
66-
neighbors.append((ring, next_sector, "radial_next"))
67-
68-
# Inner ring
50+
# DFS maze generation from center — neighbors computed inline (flat KISS structure)
51+
visited = {(0, 0)}
52+
stack = [(0, 0)]
53+
while stack:
54+
ring, sector = stack[-1]
55+
n_sec = sectors_per_ring[min(ring, len(sectors_per_ring) - 1)]
56+
nbrs = [(ring, (sector - 1) % n_sec, "radial_prev"), (ring, (sector + 1) % n_sec, "radial_next")]
6957
if ring > 0:
70-
n_sectors_inner = sectors_per_ring[min(ring - 1, len(sectors_per_ring) - 1)]
71-
inner_sector = int(sector * n_sectors_inner / n_sectors_current)
72-
neighbors.append((ring - 1, inner_sector, "arc_inner"))
73-
74-
# Outer ring
58+
n_inn = sectors_per_ring[min(ring - 1, len(sectors_per_ring) - 1)]
59+
nbrs.append((ring - 1, int(sector * n_inn / n_sec), "arc_inner"))
7560
if ring < n_rings:
76-
n_sectors_outer = sectors_per_ring[min(ring + 1, len(sectors_per_ring) - 1)]
77-
# Multiple outer sectors may correspond to this sector
78-
start_outer = int(sector * n_sectors_outer / n_sectors_current)
79-
end_outer = int((sector + 1) * n_sectors_outer / n_sectors_current)
80-
for outer_sector in range(start_outer, end_outer):
81-
neighbors.append((ring + 1, outer_sector % n_sectors_outer, "arc_outer"))
82-
83-
return neighbors
84-
85-
86-
# DFS maze generation starting from center
87-
start = (0, 0)
88-
stack = [start]
89-
visited.add(start)
90-
91-
while stack:
92-
current = stack[-1]
93-
ring, sector = current
94-
n_sectors = sectors_per_ring[min(ring, len(sectors_per_ring) - 1)]
95-
96-
# Get unvisited neighbors
97-
unvisited = []
98-
for nr, ns, wall_type in get_neighbors(ring, sector):
99-
if (nr, ns) not in visited:
100-
unvisited.append((nr, ns, wall_type))
101-
61+
n_out = sectors_per_ring[min(ring + 1, len(sectors_per_ring) - 1)]
62+
s0 = int(sector * n_out / n_sec)
63+
s1 = int((sector + 1) * n_out / n_sec)
64+
for s in range(s0, s1):
65+
nbrs.append((ring + 1, s % n_out, "arc_outer"))
66+
unvisited = [(nr, ns, wt) for nr, ns, wt in nbrs if (nr, ns) not in visited]
10267
if unvisited:
103-
# Choose random neighbor
104-
next_ring, next_sector, wall_type = unvisited[np.random.randint(len(unvisited))]
105-
106-
# Remove wall between cells
107-
if wall_type == "radial_prev":
68+
nr, ns, wt = unvisited[np.random.randint(len(unvisited))]
69+
if wt == "radial_prev":
10870
radial_walls[ring][sector] = False
109-
elif wall_type == "radial_next":
110-
next_s = (sector + 1) % n_sectors
111-
radial_walls[ring][next_s] = False
112-
elif wall_type == "arc_inner":
113-
arc_walls[ring - 1][next_sector] = False
114-
elif wall_type == "arc_outer":
71+
elif wt == "radial_next":
72+
radial_walls[ring][(sector + 1) % n_sec] = False
73+
elif wt == "arc_inner":
74+
arc_walls[ring - 1][ns] = False
75+
else:
11576
arc_walls[ring][sector] = False
116-
117-
visited.add((next_ring, next_sector))
118-
stack.append((next_ring, next_sector))
77+
visited.add((nr, ns))
78+
stack.append((nr, ns))
11979
else:
12080
stack.pop()
12181

122-
# Create entry point on outer ring
82+
# Entry gap on outer ring (sector 0 — rightmost)
83+
n_outer = sectors_per_ring[min(n_rings, len(sectors_per_ring) - 1)]
12384
entry_sector = 0
124-
outer_ring = n_rings
125-
n_outer_sectors = sectors_per_ring[min(outer_ring, len(sectors_per_ring) - 1)]
85+
entry_angle_0 = 2 * np.pi * entry_sector / n_outer
86+
gap_half = np.pi / n_outer * 1.2 # 1.2× sector half-width for a prominent entry gap
12687

127-
# Generate wall segments for plotting
88+
# Build wall segments tagged by ring depth for tapered stroke weight
12889
segments = []
12990

130-
# Outer boundary (full circle except entry)
131-
n_points = 200
132-
for i in range(n_points):
133-
theta1 = 2 * np.pi * i / n_points
134-
theta2 = 2 * np.pi * (i + 1) / n_points
135-
136-
# Leave gap for entry
137-
entry_angle = 2 * np.pi * entry_sector / n_outer_sectors
138-
entry_width = 2 * np.pi / n_outer_sectors * 0.8
139-
140-
if not (entry_angle - entry_width / 2 < (theta1 + theta2) / 2 < entry_angle + entry_width / 2):
91+
# Outer boundary with prominent entry gap — outermost ring tag
92+
n_pts = 300
93+
for i in range(n_pts):
94+
t1 = 2 * np.pi * i / n_pts
95+
t2 = 2 * np.pi * (i + 1) / n_pts
96+
t_mid = (t1 + t2) / 2
97+
if abs(t_mid - entry_angle_0) > gap_half and abs(t_mid - entry_angle_0 - 2 * np.pi) > gap_half:
14198
r = radii[n_rings + 1]
14299
segments.append(
143-
{"x": r * np.cos(theta1), "y": r * np.sin(theta1), "xend": r * np.cos(theta2), "yend": r * np.sin(theta2)}
100+
{
101+
"x": r * np.cos(t1),
102+
"y": r * np.sin(t1),
103+
"xend": r * np.cos(t2),
104+
"yend": r * np.sin(t2),
105+
"ring": n_rings + 1,
106+
}
144107
)
145108

146-
# Arc walls (between rings)
109+
# Arc walls between rings
147110
for ring in range(n_rings):
148-
n_sectors = sectors_per_ring[min(ring, len(sectors_per_ring) - 1)]
111+
n_sec = sectors_per_ring[min(ring, len(sectors_per_ring) - 1)]
149112
r = radii[ring + 1]
150-
151-
for sector in range(n_sectors):
152-
if arc_walls[ring][sector]:
153-
theta1 = 2 * np.pi * sector / n_sectors
154-
theta2 = 2 * np.pi * (sector + 1) / n_sectors
155-
156-
# Draw arc as small segments
157-
n_arc_segments = max(3, int(20 / n_sectors * 6))
158-
for j in range(n_arc_segments):
159-
t1 = theta1 + (theta2 - theta1) * j / n_arc_segments
160-
t2 = theta1 + (theta2 - theta1) * (j + 1) / n_arc_segments
113+
for sec in range(n_sec):
114+
if arc_walls[ring][sec]:
115+
t1 = 2 * np.pi * sec / n_sec
116+
t2 = 2 * np.pi * (sec + 1) / n_sec
117+
n_sub = max(3, int(120 / n_sec))
118+
for j in range(n_sub):
119+
ta = t1 + (t2 - t1) * j / n_sub
120+
tb = t1 + (t2 - t1) * (j + 1) / n_sub
161121
segments.append(
162-
{"x": r * np.cos(t1), "y": r * np.sin(t1), "xend": r * np.cos(t2), "yend": r * np.sin(t2)}
122+
{
123+
"x": r * np.cos(ta),
124+
"y": r * np.sin(ta),
125+
"xend": r * np.cos(tb),
126+
"yend": r * np.sin(tb),
127+
"ring": ring + 1,
128+
}
163129
)
164130

165-
# Radial walls
131+
# Radial walls within each ring
166132
for ring in range(n_rings + 1):
167-
n_sectors = sectors_per_ring[min(ring, len(sectors_per_ring) - 1)]
168-
r_inner = radii[ring]
169-
r_outer = radii[ring + 1]
170-
171-
for sector in range(n_sectors):
172-
if radial_walls[ring][sector]:
173-
theta = 2 * np.pi * sector / n_sectors
133+
n_sec = sectors_per_ring[min(ring, len(sectors_per_ring) - 1)]
134+
r_in, r_out = radii[ring], radii[ring + 1]
135+
for sec in range(n_sec):
136+
if radial_walls[ring][sec]:
137+
t = 2 * np.pi * sec / n_sec
174138
segments.append(
175139
{
176-
"x": r_inner * np.cos(theta),
177-
"y": r_inner * np.sin(theta),
178-
"xend": r_outer * np.cos(theta),
179-
"yend": r_outer * np.sin(theta),
140+
"x": r_in * np.cos(t),
141+
"y": r_in * np.sin(t),
142+
"xend": r_out * np.cos(t),
143+
"yend": r_out * np.sin(t),
144+
"ring": ring,
180145
}
181146
)
182147

183148
walls_df = pd.DataFrame(segments)
149+
# Taper stroke weight: outer rings thicker, inner rings thinner — depth illusion
150+
mid_ring = n_rings // 2
151+
walls_outer_df = walls_df[walls_df["ring"] >= mid_ring]
152+
walls_inner_df = walls_df[walls_df["ring"] < mid_ring]
153+
154+
# Entry gate: short highlighted arc inside the outer boundary at the entry gap
155+
entry_gate_segs = []
156+
r_gate = radii[n_rings + 1] * 0.965
157+
gate_span = gap_half
158+
n_gate = 8
159+
for j in range(n_gate):
160+
ta = entry_angle_0 - gate_span + 2 * gate_span * j / n_gate
161+
tb = entry_angle_0 - gate_span + 2 * gate_span * (j + 1) / n_gate
162+
entry_gate_segs.append(
163+
{"x": r_gate * np.cos(ta), "y": r_gate * np.sin(ta), "xend": r_gate * np.cos(tb), "yend": r_gate * np.sin(tb)}
164+
)
165+
entry_gate_df = pd.DataFrame(entry_gate_segs)
184166

185167
# Entry and goal markers
186-
# Entry is at the gap in the outer ring (sector 0, so angle near 0)
187-
entry_angle = np.pi / n_outer_sectors # Middle of sector 0
188-
entry_r = radii[n_rings + 1] + ring_width * 0.6
189-
190-
# Separate dataframes for entry and goal to avoid color mapping issues
168+
entry_angle_mid = 2 * np.pi * (entry_sector + 0.5) / n_outer
169+
entry_r = radii[n_rings + 1] + ring_width * 0.7
191170
entry_df = pd.DataFrame(
192-
{"x": [entry_r * np.cos(entry_angle)], "y": [entry_r * np.sin(entry_angle)], "label": ["START"]}
171+
{"x": [entry_r * np.cos(entry_angle_mid)], "y": [entry_r * np.sin(entry_angle_mid)], "label": ["START"]}
172+
)
173+
goal_df = pd.DataFrame({"x": [0.0], "y": [0.0], "label": ["GOAL"]})
174+
175+
# Difficulty caption below the maze
176+
caption_df = pd.DataFrame(
177+
{"x": [0.0], "y": [-(radii[n_rings + 1] + ring_width * 0.95)], "label": [f"{n_rings} rings · {difficulty}"]}
193178
)
194-
goal_df = pd.DataFrame({"x": [0], "y": [0], "label": ["GOAL"]})
195179

196-
# Create the plot
180+
# Plot
197181
plot = (
198182
ggplot()
199-
+ geom_segment(data=walls_df, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color="black", size=1.2)
200-
+ geom_point(data=goal_df, mapping=aes(x="x", y="y"), color="#306998", size=8)
183+
# Outer/mid walls — thicker stroke
184+
+ geom_segment(data=walls_outer_df, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color=INK, size=0.9)
185+
# Inner walls — thinner stroke creates depth illusion drawing eye to center
186+
+ geom_segment(data=walls_inner_df, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color=INK, size=0.45)
187+
# Entry gate marker: subtle arc at the threshold
188+
+ geom_segment(data=entry_gate_df, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color=INK_SOFT, size=1.4)
189+
# GOAL bullseye: soft glow ring behind the solid dot
190+
+ geom_point(data=goal_df, mapping=aes(x="x", y="y"), color=GOAL_COLOR, size=11, alpha=0.18)
191+
+ geom_point(data=goal_df, mapping=aes(x="x", y="y"), color=GOAL_COLOR, size=5)
201192
+ geom_text(
202-
data=goal_df, mapping=aes(x="x", y="y", label="label"), color="#FFD43B", size=14, fontweight="bold", nudge_y=0.8
193+
data=goal_df, mapping=aes(x="x", y="y", label="label"), color=GOAL_COLOR, size=9, fontweight="bold", nudge_y=0.6
203194
)
204-
+ geom_text(data=entry_df, mapping=aes(x="x", y="y", label="label"), color="#306998", size=14, fontweight="bold")
195+
+ geom_text(data=entry_df, mapping=aes(x="x", y="y", label="label"), color=INK_SOFT, size=9, fontweight="bold")
196+
+ geom_text(data=caption_df, mapping=aes(x="x", y="y", label="label"), color=INK_SOFT, size=7)
205197
+ coord_fixed(ratio=1)
206-
+ labs(title="maze-circular · plotnine · pyplots.ai")
198+
+ labs(title="maze-circular · python · plotnine · anyplot.ai")
207199
+ theme_void()
208200
+ theme(
209-
figure_size=(12, 12),
210-
plot_title=element_text(size=24, ha="center", weight="bold"),
211-
plot_background=element_blank(),
212-
panel_background=element_blank(),
201+
figure_size=(6, 6),
202+
plot_title=element_text(size=12, ha="center", weight="bold", color=INK),
203+
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
204+
panel_background=element_rect(fill=PAGE_BG),
213205
)
214206
)
215207

216208
# Save
217-
plot.save("plot.png", dpi=300)
209+
plot.save(f"plot-{THEME}.png", dpi=400, width=6, height=6, units="in")

0 commit comments

Comments
 (0)