Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit a8cd13f

Browse files
Migrate Voronoi
1 parent 0a9848b commit a8cd13f

13 files changed

Lines changed: 370 additions & 179 deletions

File tree

Home/Components/GamesNavbar.razor

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
<div>Nim</div>
77
</a>
88
</div>
9-
<!--div class="navSquare @(SelectedGameButton == GameButton.Voronoi ? "selected" : string.Empty)" id="voronoiButton">
9+
<div class="navSquare @(SelectedGameButton == GameButton.Voronoi ? "selected" : string.Empty)" id="voronoiButton">
1010
<a @onclick="NavigateToVoronoi">
11-
<img src="/images/blog.webp" alt="Voronoi" />
11+
<img src="/images/games/voronoi-icon.png" alt="Voronoi" />
1212
<div>Voronoi</div>
1313
</a>
14-
</div-->
14+
</div>
1515
</nav>

Home/Pages/Games/Nim/Models/GameState.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,9 @@ public void CreateGameBoard(int numberOfRows, ColumnConfiguration columnConfigur
3535
}
3636
break;
3737
case ColumnConfiguration.Random:
38-
Random random = new();
3938
for (int i = 0; i < numberOfRows; i++)
4039
{
41-
StartingBoard[i] = (ushort)random.Next(1, numberOfRows + 1);
40+
StartingBoard[i] = (ushort)Random.Shared.Next(1, numberOfRows + 1);
4241
}
4342
break;
4443
}
@@ -148,7 +147,7 @@ public void ChangePlayer(Action stateHasChanged)
148147

149148
int col = Random.Shared.Next(CurrentBoard[row]);
150149

151-
logger.LogInformation("AI random move: {Row} {Count}", row, col);
150+
logger.LogInformation("Computer random move: {Row} {Count}", row, col);
152151
return (row, col);
153152
}
154153
}
Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,40 @@
11
@page "/games/voronoi"
2+
@using Home.Pages.Games.Voronoi.Models
23
@layout MainLayout
34

4-
<h3>Voronoi</h3>
5+
<PageTitle>Games - Voronoi</PageTitle>
6+
7+
<Box Title="Voronoi">
8+
<svg @onclick="OnCanvasClick" class="voronoi-svg">
9+
@foreach(VoronoiRegion region in Regions)
10+
{
11+
string points = string.Join(" ", region.PolygonPoints.Select(p => $"{p.x},{p.y}"));
12+
@if (!string.IsNullOrEmpty(points))
13+
{
14+
<polygon points="@points" fill="@region.Point.Color" />
15+
}
16+
}
17+
18+
@foreach(VoronoiPoint point in State.GeneratingPoints)
19+
{
20+
<circle cx="@point.X" cy="@point.Y" r="7" fill="black" />
21+
<circle cx="@point.X" cy="@point.Y" r="5" fill="white" />
22+
<circle cx="@point.X" cy="@point.Y" r="3" fill="@point.Color" />
23+
}
24+
</svg>
25+
</Box>
26+
27+
<Box Title="Settings">
28+
<p>
29+
<a href="https://en.wikipedia.org/wiki/Voronoi_diagram" target="_blank" rel="noopener">Voronoi diagrams</a> partition a plane into regions based on distance to a set of points.
30+
Click on the SVG to add points (up to 20). Each colored region represents the area closest to that point.
31+
</p>
32+
33+
<div>
34+
<label>
35+
<input type="checkbox" @onchange="OnManhattanToggle" checked="@State.ManhattanDistance" />
36+
Use Manhattan Distance
37+
</label>
38+
<button @onclick="OnClear">Clear Points</button>
39+
</div>
40+
</Box>
Lines changed: 255 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,260 @@
1+
using Home.Pages.Games.Voronoi.Models;
2+
using Microsoft.AspNetCore.Components;
3+
using Microsoft.AspNetCore.Components.Web;
4+
using Microsoft.JSInterop;
5+
16
namespace Home.Pages.Games.Voronoi;
27

3-
public partial class Index
8+
public partial class Index(IJSRuntime JS, ILogger<Index> logger) : ComponentBase
49
{
10+
private VoronoiGameState State { get; set; } = new();
11+
private List<VoronoiRegion> Regions { get; set; } = [];
12+
private int CanvasWidth = 600;
13+
private int CanvasHeight = 600; // Keep square
14+
private const int GridResolution = 4; // Sample every 4 pixels for boundary detection
15+
private bool isUpdating = false;
16+
17+
private class DOMRectData
18+
{
19+
public double Left { get; set; }
20+
public double Top { get; set; }
21+
public double Width { get; set; }
22+
public double Height { get; set; }
23+
}
24+
25+
protected override async Task OnAfterRenderAsync(bool firstRender)
26+
{
27+
if (firstRender)
28+
{
29+
// Measure the actual SVG size and use it for calculations
30+
DOMRectData rect = await JS.InvokeAsync<DOMRectData>("eval", "document.querySelector('svg').getBoundingClientRect()");
31+
CanvasWidth = (int)rect.Width;
32+
CanvasHeight = (int)rect.Height;
33+
logger.LogInformation("SVG dimensions: {Width}x{Height}", CanvasWidth, CanvasHeight);
34+
StateHasChanged();
35+
}
36+
}
37+
38+
private async Task OnCanvasClick(MouseEventArgs e)
39+
{
40+
// Get click position relative to SVG
41+
DOMRectData rect = await JS.InvokeAsync<DOMRectData>("eval", "document.querySelector('svg').getBoundingClientRect()");
42+
int x = (int)((e.ClientX - rect.Left) * CanvasWidth / rect.Width);
43+
int y = (int)((e.ClientY - rect.Top) * CanvasHeight / rect.Height);
44+
45+
x = Math.Max(0, Math.Min(CanvasWidth - 1, x));
46+
y = Math.Max(0, Math.Min(CanvasHeight - 1, y));
47+
48+
logger.LogInformation("SVG clicked at ({X}, {Y})", x, y);
49+
State.AddPoint(x, y);
50+
await UpdateVoronoiDiagram();
51+
}
52+
53+
private async Task OnManhattanToggle(ChangeEventArgs e)
54+
{
55+
State.ToggleDistanceMode();
56+
await UpdateVoronoiDiagram();
57+
}
58+
59+
private async Task OnClear()
60+
{
61+
State.Clear();
62+
Regions.Clear();
63+
logger.LogInformation("Voronoi diagram cleared.");
64+
await Task.CompletedTask;
65+
}
66+
67+
private async Task UpdateVoronoiDiagram()
68+
{
69+
if (isUpdating)
70+
{
71+
return;
72+
}
73+
74+
isUpdating = true;
75+
try
76+
{
77+
if (State.GeneratingPoints.Count == 0)
78+
{
79+
Regions.Clear();
80+
return;
81+
}
82+
83+
// Compute regions on background thread
84+
Regions = await Task.Run(() => ComputeVoronoiRegions());
85+
logger.LogInformation("Voronoi diagram computed with {RegionCount} regions.", Regions.Count);
86+
}
87+
finally
88+
{
89+
isUpdating = false;
90+
StateHasChanged();
91+
}
92+
}
93+
94+
private List<VoronoiRegion> ComputeVoronoiRegions()
95+
{
96+
// Create a grid mapping each cell to its closest point
97+
int gridWidth = (CanvasWidth / GridResolution) + 1;
98+
int gridHeight = (CanvasHeight / GridResolution) + 1;
99+
VoronoiPoint[,] grid = new VoronoiPoint[gridWidth, gridHeight];
100+
101+
// Sample the grid
102+
for (int gy = 0; gy < gridHeight; gy++)
103+
{
104+
for (int gx = 0; gx < gridWidth; gx++)
105+
{
106+
int sampleX = gx * GridResolution;
107+
int sampleY = gy * GridResolution;
108+
109+
VoronoiPoint closestPoint = State.GeneratingPoints[0];
110+
int closestDist = closestPoint.Distance(sampleX, sampleY, State.ManhattanDistance);
111+
112+
for (int i = 1; i < State.GeneratingPoints.Count; i++)
113+
{
114+
int dist = State.GeneratingPoints[i].Distance(sampleX, sampleY, State.ManhattanDistance);
115+
if (dist < closestDist)
116+
{
117+
closestDist = dist;
118+
closestPoint = State.GeneratingPoints[i];
119+
}
120+
}
121+
122+
grid[gx, gy] = closestPoint;
123+
}
124+
}
125+
126+
// Find boundaries and create regions
127+
Dictionary<VoronoiPoint, List<(int x, int y)>> regions = [];
128+
HashSet<(int, int)> visited = [];
129+
130+
for (int gy = 0; gy < gridHeight; gy++)
131+
{
132+
for (int gx = 0; gx < gridWidth; gx++)
133+
{
134+
VoronoiPoint point = grid[gx, gy];
135+
136+
if (!regions.ContainsKey(point))
137+
{
138+
regions[point] = [];
139+
}
140+
141+
// Add the pixel coordinates that are on the boundary of this point
142+
int pixelX = gx * GridResolution;
143+
int pixelY = gy * GridResolution;
144+
145+
// Check if this is a boundary cell (different from a neighbor)
146+
bool isBoundary = false;
147+
if (gx == 0 || gx == gridWidth - 1 || gy == 0 || gy == gridHeight - 1)
148+
{
149+
isBoundary = true;
150+
}
151+
else if (gx > 0 && grid[gx - 1, gy] != point)
152+
{
153+
isBoundary = true;
154+
}
155+
else if (gx < gridWidth - 1 && grid[gx + 1, gy] != point)
156+
{
157+
isBoundary = true;
158+
}
159+
else if (gy > 0 && grid[gx, gy - 1] != point)
160+
{
161+
isBoundary = true;
162+
}
163+
else if (gy < gridHeight - 1 && grid[gx, gy + 1] != point)
164+
{
165+
isBoundary = true;
166+
}
167+
168+
if (isBoundary && !visited.Contains((gx, gy)))
169+
{
170+
regions[point].Add((pixelX, pixelY));
171+
visited.Add((gx, gy));
172+
}
173+
}
174+
}
175+
176+
// Convert to VoronoiRegion objects and compute final polygon boundaries
177+
List<VoronoiRegion> result = [];
178+
foreach ((VoronoiPoint? point, List<(int x, int y)>? pixels) in regions)
179+
{
180+
VoronoiRegion region = new()
181+
{
182+
Point = point
183+
};
184+
185+
if (pixels.Count > 0)
186+
{
187+
// Sort them radially around the point's center
188+
List<(int x, int y)> sortedPixels = pixels
189+
.OrderBy(p => Math.Atan2(p.y - point.Y, p.x - point.X))
190+
.ToList();
191+
192+
// Simplify the polygon to smooth jagged edges
193+
List<(int x, int y)> simplifiedPixels = SimplifyPolygon(sortedPixels, tolerance: 10);
194+
region.PolygonPoints = simplifiedPixels;
195+
}
196+
197+
if (region.PolygonPoints.Count > 2)
198+
{
199+
result.Add(region);
200+
}
201+
}
202+
203+
return result;
204+
}
205+
206+
private List<(int x, int y)> SimplifyPolygon(List<(int x, int y)> points, double tolerance)
207+
{
208+
if (points.Count <= 2)
209+
{
210+
return points;
211+
}
212+
213+
// Douglas-Peucker algorithm for polygon simplification
214+
double dmax = 0.0;
215+
int index = 0;
216+
217+
for (int i = 1; i < points.Count - 1; i++)
218+
{
219+
double d = PointToLineDistance(points[i], points[0], points[^1]);
220+
if (d > dmax)
221+
{
222+
index = i;
223+
dmax = d;
224+
}
225+
}
226+
227+
if (dmax > tolerance)
228+
{
229+
List<(int x, int y)> rec1 = SimplifyPolygon(points.GetRange(0, index + 1), tolerance);
230+
List<(int x, int y)> rec2 = SimplifyPolygon(points.GetRange(index, points.Count - index), tolerance);
231+
232+
List<(int x, int y)> result = rec1.GetRange(0, rec1.Count - 1);
233+
result.AddRange(rec2);
234+
return result;
235+
}
236+
else
237+
{
238+
return [points[0], points[^1]];
239+
}
240+
}
241+
242+
private double PointToLineDistance((int x, int y) point, (int x, int y) lineStart, (int x, int y) lineEnd)
243+
{
244+
int dx = lineEnd.x - lineStart.x;
245+
int dy = lineEnd.y - lineStart.y;
246+
247+
if (dx == 0 && dy == 0)
248+
{
249+
return Math.Sqrt(Math.Pow(point.x - lineStart.x, 2) + Math.Pow(point.y - lineStart.y, 2));
250+
}
251+
252+
int t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / (dx * dx + dy * dy);
253+
t = Math.Max(0, Math.Min(1, t));
254+
255+
int closestX = lineStart.x + t * dx;
256+
int closestY = lineStart.y + t * dy;
5257

258+
return Math.Sqrt(Math.Pow(point.x - closestX, 2) + Math.Pow(point.y - closestY, 2));
259+
}
6260
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.voronoi-svg {
2+
display: block;
3+
width: 100%;
4+
aspect-ratio: 1;
5+
max-width: 100%;
6+
border: 1px solid #999;
7+
background-color: black;
8+
cursor: crosshair;
9+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
namespace Home.Pages.Games.Voronoi.Models;
2+
3+
public class VoronoiGameState
4+
{
5+
private const int MaxPoints = 20;
6+
public List<VoronoiPoint> GeneratingPoints { get; } = [];
7+
public bool ManhattanDistance { get; set; } = false;
8+
9+
public void AddPoint(int x, int y)
10+
{
11+
if (GeneratingPoints.Count >= MaxPoints)
12+
{
13+
GeneratingPoints.RemoveAt(0);
14+
}
15+
16+
GeneratingPoints.Add(new VoronoiPoint
17+
{
18+
X = x,
19+
Y = y,
20+
Color = GenerateRandomColor()
21+
});
22+
}
23+
24+
public void Clear()
25+
{
26+
GeneratingPoints.Clear();
27+
}
28+
29+
public void ToggleDistanceMode()
30+
{
31+
ManhattanDistance = !ManhattanDistance;
32+
}
33+
34+
private static string GenerateRandomColor()
35+
{
36+
int r = Random.Shared.Next(256);
37+
int g = Random.Shared.Next(256);
38+
int b = Random.Shared.Next(256);
39+
return $"rgb({r}, {g}, {b})";
40+
}
41+
}

0 commit comments

Comments
 (0)