|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import os |
| 8 | + |
7 | 9 | import numpy as np |
8 | 10 | import pandas as pd |
9 | 11 | from plotnine import ( |
10 | 12 | aes, |
11 | 13 | coord_fixed, |
12 | | - element_blank, |
| 14 | + element_rect, |
13 | 15 | element_text, |
14 | 16 | geom_point, |
15 | 17 | geom_segment, |
|
21 | 23 | ) |
22 | 24 |
|
23 | 25 |
|
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 |
25 | 34 | np.random.seed(42) |
26 | | -n_rings = 7 |
27 | | -sectors_per_ring = [1, 6, 12, 18, 24, 30, 36, 42] # Increasing sectors outward |
28 | 35 | 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] |
29 | 39 | ring_width = 1.0 |
30 | | - |
31 | | -# Calculate radii for each ring |
32 | 40 | radii = [i * ring_width for i in range(n_rings + 2)] |
33 | 41 |
|
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 |
39 | 43 | radial_walls = [] |
40 | 44 | arc_walls = [] |
41 | | - |
42 | 45 | 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) |
55 | 49 |
|
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")] |
69 | 57 | 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")) |
75 | 60 | 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] |
102 | 67 | 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": |
108 | 70 | 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: |
115 | 76 | 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)) |
119 | 79 | else: |
120 | 80 | stack.pop() |
121 | 81 |
|
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)] |
123 | 84 | 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 |
126 | 87 |
|
127 | | -# Generate wall segments for plotting |
| 88 | +# Build wall segments tagged by ring depth for tapered stroke weight |
128 | 89 | segments = [] |
129 | 90 |
|
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: |
141 | 98 | r = radii[n_rings + 1] |
142 | 99 | 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 | + } |
144 | 107 | ) |
145 | 108 |
|
146 | | -# Arc walls (between rings) |
| 109 | +# Arc walls between rings |
147 | 110 | 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)] |
149 | 112 | 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 |
161 | 121 | 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 | + } |
163 | 129 | ) |
164 | 130 |
|
165 | | -# Radial walls |
| 131 | +# Radial walls within each ring |
166 | 132 | 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 |
174 | 138 | segments.append( |
175 | 139 | { |
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, |
180 | 145 | } |
181 | 146 | ) |
182 | 147 |
|
183 | 148 | 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) |
184 | 166 |
|
185 | 167 | # 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 |
191 | 170 | 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}"]} |
193 | 178 | ) |
194 | | -goal_df = pd.DataFrame({"x": [0], "y": [0], "label": ["GOAL"]}) |
195 | 179 |
|
196 | | -# Create the plot |
| 180 | +# Plot |
197 | 181 | plot = ( |
198 | 182 | 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) |
201 | 192 | + 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 |
203 | 194 | ) |
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) |
205 | 197 | + coord_fixed(ratio=1) |
206 | | - + labs(title="maze-circular · plotnine · pyplots.ai") |
| 198 | + + labs(title="maze-circular · python · plotnine · anyplot.ai") |
207 | 199 | + theme_void() |
208 | 200 | + 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), |
213 | 205 | ) |
214 | 206 | ) |
215 | 207 |
|
216 | 208 | # 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