Voronoi Diagram for Spatial Partitioning — Pygal

A Voronoi diagram partitions a plane into regions based on the distance to a set of seed points, where each region contains all points closer to its seed than to any other. This visualization is essential for understanding spatial relationships, proximity analysis, and territorial boundaries. It reveals natural clustering patterns and helps identify areas of influence around data points.

Voronoi Diagram for Spatial Partitioning rendered with Pygal

Python source (Pygal)

""" anyplot.ai
voronoi-basic: Voronoi Diagram for Spatial Partitioning
Library: pygal 3.1.0 | Python 3.13.13
Quality: 73/100 | Updated: 2026-05-17
"""

import os
import xml.etree.ElementTree as ET

import cairosvg
import numpy as np
from scipy.spatial import Voronoi


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
BRAND = "#009E73"

IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477")

np.random.seed(42)
points = np.random.uniform(0.5, 10.5, size=(25, 2))

vor = Voronoi(points)

WIDTH = 4800
HEIGHT = 2700
margin_left = 400
margin_right = 200
margin_top = 300
margin_bottom = 250

plot_width = WIDTH - margin_left - margin_right
plot_height = HEIGHT - margin_top - margin_bottom

x_min, x_max = 0.5, 10.5
y_min, y_max = 0.5, 10.5


def data_to_svg(x, y):
    svg_x = margin_left + (x - x_min) / (x_max - x_min) * plot_width
    svg_y = margin_top + (y_max - y) / (y_max - y_min) * plot_height
    return svg_x, svg_y


def voronoi_finite_polygons_2d(vor, radius=None):
    if vor.points.shape[1] != 2:
        raise ValueError("Requires 2D input")

    new_regions = []
    new_vertices = vor.vertices.tolist()

    center = vor.points.mean(axis=0)
    if radius is None:
        radius = vor.points.ptp().max() * 2

    all_ridges = {}
    for (p1, p2), (v1, v2) in zip(vor.ridge_points, vor.ridge_vertices, strict=True):
        all_ridges.setdefault(p1, []).append((p2, v1, v2))
        all_ridges.setdefault(p2, []).append((p1, v1, v2))

    for p1, region in enumerate(vor.point_region):
        vertices = vor.regions[region]

        if all(v >= 0 for v in vertices):
            new_regions.append(vertices)
            continue

        ridges = all_ridges[p1]
        new_region = [v for v in vertices if v >= 0]

        for p2, v1, v2 in ridges:
            if v2 < 0:
                v1, v2 = v2, v1
            if v1 >= 0:
                continue

            t = vor.points[p2] - vor.points[p1]
            t /= np.linalg.norm(t)
            n = np.array([-t[1], t[0]])

            midpoint = vor.points[[p1, p2]].mean(axis=0)
            direction = np.sign(np.dot(midpoint - center, n)) * n
            far_point = vor.vertices[v2] + direction * radius

            new_region.append(len(new_vertices))
            new_vertices.append(far_point.tolist())

        vs = np.asarray([new_vertices[v] for v in new_region])
        c = vs.mean(axis=0)
        angles = np.arctan2(vs[:, 1] - c[1], vs[:, 0] - c[0])
        new_region = np.array(new_region)[np.argsort(angles)].tolist()

        new_regions.append(new_region)

    return new_regions, np.asarray(new_vertices)


def clip_polygon_to_box(vertices, x_min, x_max, y_min, y_max):
    def inside_edge(p, edge):
        x, y = p
        if edge == "left":
            return x >= x_min
        if edge == "right":
            return x <= x_max
        if edge == "bottom":
            return y >= y_min
        if edge == "top":
            return y <= y_max

    def intersect(p1, p2, edge):
        x1, y1 = p1
        x2, y2 = p2
        dx = x2 - x1
        dy = y2 - y1

        if edge == "left":
            t = (x_min - x1) / (dx + 1e-12)
        elif edge == "right":
            t = (x_max - x1) / (dx + 1e-12)
        elif edge == "bottom":
            t = (y_min - y1) / (dy + 1e-12)
        else:
            t = (y_max - y1) / (dy + 1e-12)

        return (x1 + t * dx, y1 + t * dy)

    output = list(vertices)

    for edge in ["left", "right", "bottom", "top"]:
        if len(output) == 0:
            break
        input_list = output
        output = []

        for i in range(len(input_list)):
            current = input_list[i]
            prev = input_list[i - 1]

            if inside_edge(current, edge):
                if not inside_edge(prev, edge):
                    output.append(intersect(prev, current, edge))
                output.append(current)
            elif inside_edge(prev, edge):
                output.append(intersect(prev, current, edge))

    return output


regions, vertices = voronoi_finite_polygons_2d(vor, radius=20)

svg_ns = "http://www.w3.org/2000/svg"
ET.register_namespace("", svg_ns)

svg_root = ET.Element("svg", xmlns=svg_ns, width=str(WIDTH), height=str(HEIGHT), viewBox=f"0 0 {WIDTH} {HEIGHT}")
svg_root.set("style", f"background-color: {PAGE_BG};")

title_elem = ET.SubElement(svg_root, "text")
title_elem.set("x", str(WIDTH / 2))
title_elem.set("y", "150")
title_elem.set("text-anchor", "middle")
title_elem.set("fill", INK)
title_elem.set("font-size", "28")
title_elem.set("font-family", "sans-serif")
title_elem.set("font-weight", "500")
title_elem.text = "voronoi-basic · pygal · anyplot.ai"

x_label = ET.SubElement(svg_root, "text")
x_label.set("x", str(margin_left + plot_width / 2))
x_label.set("y", str(HEIGHT - 80))
x_label.set("text-anchor", "middle")
x_label.set("fill", INK)
x_label.set("font-size", "22")
x_label.set("font-family", "sans-serif")
x_label.text = "X Coordinate (units)"

y_label = ET.SubElement(svg_root, "text")
y_label.set("x", "110")
y_label.set("y", str(margin_top + plot_height / 2))
y_label.set("text-anchor", "middle")
y_label.set("fill", INK)
y_label.set("font-size", "22")
y_label.set("font-family", "sans-serif")
y_label.set("transform", f"rotate(-90, 110, {margin_top + plot_height / 2})")
y_label.text = "Y Coordinate (units)"

plot_bg = ET.SubElement(svg_root, "rect")
plot_bg.set("x", str(margin_left))
plot_bg.set("y", str(margin_top))
plot_bg.set("width", str(plot_width))
plot_bg.set("height", str(plot_height))
plot_bg.set("fill", PAGE_BG)
plot_bg.set("stroke", INK_SOFT)
plot_bg.set("stroke-width", "2")

defs = ET.SubElement(svg_root, "defs")
clip_path = ET.SubElement(defs, "clipPath", id="plot-area")
clip_rect = ET.SubElement(clip_path, "rect")
clip_rect.set("x", str(margin_left))
clip_rect.set("y", str(margin_top))
clip_rect.set("width", str(plot_width))
clip_rect.set("height", str(plot_height))

grid_g = ET.SubElement(svg_root, "g")
grid_g.set("class", "grid")

grid_color = "rgba(26,26,23,0.10)" if THEME == "light" else "rgba(240,239,232,0.10)"

for x_val in np.arange(1, 11, 1):
    sx, _ = data_to_svg(x_val, 0)
    _, sy_top = data_to_svg(0, y_max)
    _, sy_bot = data_to_svg(0, y_min)
    line = ET.SubElement(grid_g, "line")
    line.set("x1", f"{sx:.1f}")
    line.set("y1", f"{sy_top:.1f}")
    line.set("x2", f"{sx:.1f}")
    line.set("y2", f"{sy_bot:.1f}")
    line.set("stroke", INK_SOFT)
    line.set("stroke-width", "1")
    line.set("opacity", "0.15")

for y_val in np.arange(1, 11, 1):
    _, sy = data_to_svg(0, y_val)
    sx_left, _ = data_to_svg(x_min, 0)
    sx_right, _ = data_to_svg(x_max, 0)
    line = ET.SubElement(grid_g, "line")
    line.set("x1", f"{sx_left:.1f}")
    line.set("y1", f"{sy:.1f}")
    line.set("x2", f"{sx_right:.1f}")
    line.set("y2", f"{sy:.1f}")
    line.set("stroke", INK_SOFT)
    line.set("stroke-width", "1")
    line.set("opacity", "0.15")

for x_val in range(1, 11, 2):
    sx, _ = data_to_svg(x_val, 0)
    tick_label = ET.SubElement(svg_root, "text")
    tick_label.set("x", f"{sx:.1f}")
    tick_label.set("y", str(margin_top + plot_height + 60))
    tick_label.set("text-anchor", "middle")
    tick_label.set("fill", INK_SOFT)
    tick_label.set("font-size", "18")
    tick_label.set("font-family", "sans-serif")
    tick_label.text = str(x_val)

for y_val in range(1, 11, 2):
    _, sy = data_to_svg(0, y_val)
    tick_label = ET.SubElement(svg_root, "text")
    tick_label.set("x", str(margin_left - 40))
    tick_label.set("y", f"{sy + 8:.1f}")
    tick_label.set("text-anchor", "end")
    tick_label.set("fill", INK_SOFT)
    tick_label.set("font-size", "18")
    tick_label.set("font-family", "sans-serif")
    tick_label.text = str(y_val)

cells_g = ET.SubElement(svg_root, "g")
cells_g.set("class", "voronoi-cells")
cells_g.set("clip-path", "url(#plot-area)")

for i, region in enumerate(regions):
    if not region or len(region) < 3:
        continue

    poly_verts = [vertices[v] for v in region]
    clipped = clip_polygon_to_box(poly_verts, x_min, x_max, y_min, y_max)

    if len(clipped) < 3:
        continue

    svg_points = [data_to_svg(x, y) for x, y in clipped]
    points_str = " ".join([f"{x:.1f},{y:.1f}" for x, y in svg_points])

    polygon = ET.SubElement(cells_g, "polygon")
    polygon.set("points", points_str)
    color = IMPRINT[i % len(IMPRINT)]
    polygon.set("fill", color)
    polygon.set("fill-opacity", "0.6")
    polygon.set("stroke", INK_SOFT)
    polygon.set("stroke-width", "2")

    title = ET.SubElement(polygon, "title")
    title.text = f"Region {i + 1}: ({vor.points[i][0]:.2f}, {vor.points[i][1]:.2f})"

points_g = ET.SubElement(svg_root, "g")
points_g.set("class", "seed-points")

for i, (px, py) in enumerate(vor.points):
    sx, sy = data_to_svg(px, py)

    outer = ET.SubElement(points_g, "circle")
    outer.set("cx", f"{sx:.1f}")
    outer.set("cy", f"{sy:.1f}")
    outer.set("r", "16")
    outer.set("fill", PAGE_BG)
    outer.set("stroke", INK_SOFT)
    outer.set("stroke-width", "2")

    inner = ET.SubElement(points_g, "circle")
    inner.set("cx", f"{sx:.1f}")
    inner.set("cy", f"{sy:.1f}")
    inner.set("r", "12")
    color = IMPRINT[i % len(IMPRINT)]
    inner.set("fill", color)

    title = ET.SubElement(inner, "title")
    title.text = f"Point {i + 1}: ({px:.2f}, {py:.2f})"

svg_output = ET.tostring(svg_root, encoding="unicode")
with open(f"plot-{THEME}.html", "w") as f:
    f.write(svg_output)

cairosvg.svg2png(bytestring=svg_output.encode("utf-8"), write_to=f"plot-{THEME}.png")

Part of Voronoi Diagram for Spatial Partitioning on anyplot.ai.

Other implementations