1. Introduction
In this tutorial, we’ll build an animated visualization using Tkinter, Python’s built-in GUI toolkit. The program simulates particles drifting across the screen, connecting with lines when they are close enough—forming constellation-like patterns.
2. Prerequisites
- Python 3.x installed on your system
- Basic understanding of Python syntax
- No external libraries required—only Tkinter (bundled with Python)
3. Setting Up the Canvas
We start by creating a Tkinter window and a canvas where particles will move:
import tkinter as tk
WIDTH, HEIGHT = 900, 600
root = tk.Tk()
root.title("Particle Constellations")
canvas = tk.Canvas(root, width=WIDTH, height=HEIGHT, bg="#0b0f1a", highlightthickness=0)
canvas.pack()
4. Creating Particles
Each particle has a position, velocity, and a small circle drawn on the canvas:
import random, math
class Particle:
def __init__(self, canvas):
self.canvas = canvas
self.x = random.uniform(0, WIDTH)
self.y = random.uniform(0, HEIGHT)
angle = random.uniform(0, 2*math.pi)
speed = random.uniform(0.3, 1.8)
self.vx = math.cos(angle) * speed
self.vy = math.sin(angle) * speed
self.id = canvas.create_oval(self.x-2, self.y-2, self.x+2, self.y+2,
fill="#dbe7ff", outline="")
5. Updating Particle Motion
We update positions each frame and wrap around edges for a seamless starfield:
def update(self):
self.x += self.vx
self.y += self.vy
if self.x < 0: self.x += WIDTH
if self.x > WIDTH: self.x -= WIDTH
if self.y < 0: self.y += HEIGHT
if self.y > HEIGHT: self.y -= HEIGHT
self.canvas.coords(self.id, self.x-2, self.y-2, self.x+2, self.y+2)
6. Drawing Constellation Lines
We connect particles that are within a certain distance:
def draw_links(canvas, particles, max_dist=85):
for i, p1 in enumerate(particles):
for j, p2 in enumerate(particles[i+1:], i+1):
dx, dy = p2.x - p1.x, p2.y - p1.y
d = math.hypot(dx, dy)
if d < max_dist:
fade = 1 - d/max_dist
gray = int(200 * fade) + 55
color = f"#{gray:02x}{gray:02x}{gray:02x}"
canvas.create_line(p1.x, p1.y, p2.x, p2.y, fill=color)
7. Animation Loop
We continuously update particles and redraw lines:
particles = [Particle(canvas) for _ in range(150)]
def animate():
canvas.delete("line") # clear old lines
for p in particles:
p.update()
draw_links(canvas, particles)
root.after(16, animate) # ~60 FPS
animate()
root.mainloop()
8. Enhancements
- Adjust
max_distfor denser or sparser constellations. - Change particle speed for calmer or more energetic motion.
- Add “anchor stars” to guide particles into recognizable constellation shapes.
- Experiment with colors for different moods (deep blues, purples, or monochrome).
9. Full code
import tkinter as tk
import random
import math
from dataclasses import dataclass
# --- Config ---
WIDTH, HEIGHT = 900, 600
NUM_PARTICLES = 180
PARTICLE_RADIUS = 2
MAX_SPEED = 1.8
LINK_DISTANCE = 85 # pixels: max distance to draw a line
LINK_ALPHA = 0.35 # line opacity (0..1), simulated via grayscale
BG = "#0b0f1a" # deep night-sky
PARTICLE_COLOR = "#dbe7ff"
LINE_COLOR_BASE = 230 # higher -> brighter lines (0..255)
FADE_EDGES = True # fade particles near edges for a vignette feel
# Optional “anchor stars” to suggest constellation structure (tuples of x,y)
ANCHORS = [
(WIDTH * 0.15, HEIGHT * 0.30),
(WIDTH * 0.25, HEIGHT * 0.50),
(WIDTH * 0.35, HEIGHT * 0.35),
(WIDTH * 0.50, HEIGHT * 0.40),
(WIDTH * 0.60, HEIGHT * 0.60),
(WIDTH * 0.70, HEIGHT * 0.30),
(WIDTH * 0.80, HEIGHT * 0.50),
]
# Influence of anchors on nearby particles
ANCHOR_PULL = 0.06 # acceleration towards nearest anchor
ANCHOR_RADIUS = 160 # only pull particles within this radius
@dataclass
class Particle:
x: float
y: float
vx: float
vy: float
oval_id: int
class ParticleConstellations:
def __init__(self, root):
self.root = root
self.root.title("Animated Particle Constellations — Tkinter")
self.canvas = tk.Canvas(root, width=WIDTH, height=HEIGHT, bg=BG, highlightthickness=0)
self.canvas.pack(fill="both", expand=True)
# UI controls
self.toolbar = tk.Frame(root, bg=BG)
self.toolbar.pack(fill="x")
self.show_links = tk.BooleanVar(value=True)
tk.Checkbutton(self.toolbar, text="Show Constellations", variable=self.show_links,
fg="white", bg=BG, activeforeground="white", selectcolor=BG).pack(side="left", padx=8)
tk.Button(self.toolbar, text="Randomize", command=self.randomize,
fg="white", bg="#1b2234", activebackground="#243050").pack(side="left", padx=8)
tk.Label(self.toolbar, text="Particles:", fg="white", bg=BG).pack(side="left", padx=(16,6))
self.num_var = tk.IntVar(value=NUM_PARTICLES)
tk.Spinbox(self.toolbar, from_=30, to=600, width=5, command=self.reset_particles,
textvariable=self.num_var, fg="white", bg="#1b2234", insertbackground="white").pack(side="left", padx=6)
self.particles = []
self.lines = [] # line ids
self.anchor_ids = []
self.create_anchors()
self.create_particles(self.num_var.get())
# Animation loop
self.running = True
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.animate()
def create_anchors(self):
self.anchor_ids.clear()
for ax, ay in ANCHORS:
s = 3
aid = self.canvas.create_oval(ax - s, ay - s, ax + s, ay + s,
fill="#f3f6ff", outline="")
self.anchor_ids.append(aid)
def create_particles(self, n):
# Clear existing particles
for p in self.particles:
self.canvas.delete(p.oval_id)
self.particles = []
for _ in range(n):
x = random.uniform(0, WIDTH)
y = random.uniform(0, HEIGHT)
speed = random.uniform(0.3, MAX_SPEED)
angle = random.uniform(0, 2 * math.pi)
vx = math.cos(angle) * speed
vy = math.sin(angle) * speed
pid = self.canvas.create_oval(x - PARTICLE_RADIUS, y - PARTICLE_RADIUS,
x + PARTICLE_RADIUS, y + PARTICLE_RADIUS,
fill=PARTICLE_COLOR, outline="")
self.particles.append(Particle(x, y, vx, vy, pid))
def reset_particles(self):
self.create_particles(self.num_var.get())
def randomize(self):
# Jitter positions and velocities
for p in self.particles:
p.x = random.uniform(0, WIDTH)
p.y = random.uniform(0, HEIGHT)
speed = random.uniform(0.3, MAX_SPEED)
angle = random.uniform(0, 2 * math.pi)
p.vx = math.cos(angle) * speed
p.vy = math.sin(angle) * speed
def on_close(self):
self.running = False
self.root.destroy()
def animate(self):
if not self.running:
return
# Physics update
for p in self.particles:
# Anchor attraction (nearest anchor)
if ANCHORS and ANCHOR_PULL > 0:
ax, ay, dist = self.nearest_anchor(p.x, p.y)
if dist < ANCHOR_RADIUS:
dx, dy = ax - p.x, ay - p.y
# normalize and apply pull
if dist > 1e-4:
p.vx += (dx / dist) * ANCHOR_PULL
p.vy += (dy / dist) * ANCHOR_PULL
# Soft boundary wrap with damping
p.x += p.vx
p.y += p.vy
# Wrap-around for a seamless starfield
if p.x < 0: p.x += WIDTH
if p.x > WIDTH: p.x -= WIDTH
if p.y < 0: p.y += HEIGHT
if p.y > HEIGHT: p.y -= HEIGHT
# Render particles
for p in self.particles:
self.canvas.coords(p.oval_id,
p.x - PARTICLE_RADIUS, p.y - PARTICLE_RADIUS,
p.x + PARTICLE_RADIUS, p.y + PARTICLE_RADIUS)
if FADE_EDGES:
# Simulate vignette by adjusting color near borders
edge_pad = 180
dx = min(p.x, WIDTH - p.x)
dy = min(p.y, HEIGHT - p.y)
m = min(dx, dy) / edge_pad
m = max(0.25, min(1.0, m))
# compute grayscale based on m
gray = int(200 * m) + 55
color = f"#{gray:02x}{gray:02x}{gray:02x}"
self.canvas.itemconfig(p.oval_id, fill=color)
else:
self.canvas.itemconfig(p.oval_id, fill=PARTICLE_COLOR)
# Render constellation lines
self.clear_lines()
if self.show_links.get():
self.draw_links()
# Schedule next frame
self.root.after(16, self.animate) # ~60 FPS
def nearest_anchor(self, x, y):
best = None
bestd = 1e9
for ax, ay in ANCHORS:
dx, dy = ax - x, ay - y
d = math.hypot(dx, dy)
if d < bestd:
bestd = d
best = (ax, ay)
return best[0], best[1], bestd
def clear_lines(self):
for lid in self.lines:
self.canvas.delete(lid)
self.lines.clear()
def draw_links(self):
# Spatial hashing for efficiency (coarse grid)
cell = int(LINK_DISTANCE)
grid = {}
for i, p in enumerate(self.particles):
gx = int(p.x // cell)
gy = int(p.y // cell)
grid.setdefault((gx, gy), []).append(i)
# Neighbor cells to check
neighbors = [(dx, dy) for dx in (-1, 0, 1) for dy in (-1, 0, 1)]
for (gx, gy), indices in grid.items():
# Check nearby cells
candidates = []
for dx, dy in neighbors:
candidates.extend(grid.get((gx + dx, gy + dy), []))
for i in indices:
p1 = self.particles[i]
for j in candidates:
if j <= i:
continue
p2 = self.particles[j]
dx = p2.x - p1.x
dy = p2.y - p1.y
d = math.hypot(dx, dy)
if d < LINK_DISTANCE:
# Fade line by distance
fade = 1.0 - (d / LINK_DISTANCE)
alpha = LINK_ALPHA * fade
# Simulate alpha using grayscale intensity
gray = max(30, min(255, int(LINE_COLOR_BASE * alpha)))
color = f"#{gray:02x}{gray:02x}{gray:02x}"
lid = self.canvas.create_line(p1.x, p1.y, p2.x, p2.y,
fill=color, width=1)
self.lines.append(lid)
if __name__ == "__main__":
root = tk.Tk()
app = ParticleConstellations(root)
root.mainloop()
Next Python tutorial: Plasma effect