diff --git a/cs/TagsCloudVisualization/Layouter/CircularCloudLayouter.cs b/cs/TagsCloudVisualization/Layouter/CircularCloudLayouter.cs new file mode 100644 index 000000000..623f24286 --- /dev/null +++ b/cs/TagsCloudVisualization/Layouter/CircularCloudLayouter.cs @@ -0,0 +1,57 @@ +using SkiaSharp; + +namespace TagsCloudVisualization.Layouter +{ + public class CircularCloudLayouter : ICircularCloudLayouter + { + private readonly List rectangles; + private readonly SKPoint center; + private double angle; + private const double Step = 0.1; + + public CircularCloudLayouter(SKPoint center) + { + rectangles = new List(); + this.center = center; + } + + public SKRect PutNextRectangle(SKSize rectangleSize) + { + if (rectangleSize.Width <= 0 || rectangleSize.Height <= 0) + throw new ArgumentException("Rectangle size must be positive", nameof(rectangleSize)); + + SKRect rectangle; + + do + { + var centerOfRectangle = GetNextPosition(); + var rectanglePosition = new SKPoint(centerOfRectangle.X - rectangleSize.Width / 2, + centerOfRectangle.Y - rectangleSize.Height / 2); + rectangle = new SKRect( + rectanglePosition.X, + rectanglePosition.Y, + rectanglePosition.X + rectangleSize.Width, + rectanglePosition.Y + rectangleSize.Height); + } while (rectangles.Any(r => r.IntersectsWith(rectangle))); + + rectangles.Add(rectangle); + return rectangle; + } + + public SKRect[] GetRectangles() + { + return rectangles.ToArray(); + } + + private SKPoint GetNextPosition() + { + var radius = Step * angle; + var x = (float)(center.X + radius * Math.Cos(angle)); + var y = (float)(center.Y + radius * Math.Sin(angle)); + + angle += Step; + + return new SKPoint(x, y); + } + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/Layouter/ICircularCloudLayouter.cs b/cs/TagsCloudVisualization/Layouter/ICircularCloudLayouter.cs new file mode 100644 index 000000000..e73b422e3 --- /dev/null +++ b/cs/TagsCloudVisualization/Layouter/ICircularCloudLayouter.cs @@ -0,0 +1,9 @@ +using SkiaSharp; + +namespace TagsCloudVisualization.Layouter; + +public interface ICircularCloudLayouter +{ + SKRect PutNextRectangle(SKSize rectangleSize); + SKRect[] GetRectangles(); +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/Program.cs b/cs/TagsCloudVisualization/Program.cs new file mode 100644 index 000000000..58209b119 --- /dev/null +++ b/cs/TagsCloudVisualization/Program.cs @@ -0,0 +1,32 @@ +using SkiaSharp; +using TagsCloudVisualization.Layouter; +using TagsCloudVisualization.Renderer; + +internal class Program +{ + private static void Main() + { + Directory.CreateDirectory("results"); + + RenderCloud(GenerateRandomCloud(10), "results/cloud_10.png"); + RenderCloud(GenerateRandomCloud(50), "results/cloud_50.png"); + RenderCloud(GenerateRandomCloud(100), "results/cloud_100.png"); + } + + private static SKRect[] GenerateRandomCloud(int count) + { + var layouter = new CircularCloudLayouter(new SKPoint(500, 500)); + var rectangleSizes = Enumerable.Range(0, count) + .Select(_ => new SKSize(new Random().Next(10, 100), new Random().Next(10, 100))); + return rectangleSizes.Select(layouter.PutNextRectangle).ToArray(); + } + + private static void RenderCloud(SKRect[] rectangles, string path) + { + var renderer = new Renderer(new SKSize(1000, 1000)); + renderer.DrawRectangles(rectangles); + var image = renderer.GetEncodedImage(); + using var stream = File.OpenWrite(path); + image.SaveTo(stream); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/Renderer/IRenderer.cs b/cs/TagsCloudVisualization/Renderer/IRenderer.cs new file mode 100644 index 000000000..92f388842 --- /dev/null +++ b/cs/TagsCloudVisualization/Renderer/IRenderer.cs @@ -0,0 +1,11 @@ +using SkiaSharp; + +namespace TagsCloudVisualization.Renderer; + +public interface IRenderer +{ + void DrawRectangles(IEnumerable rectangles); + + SKData GetEncodedImage(); + +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/Renderer/Renderer.cs b/cs/TagsCloudVisualization/Renderer/Renderer.cs new file mode 100644 index 000000000..74ca25730 --- /dev/null +++ b/cs/TagsCloudVisualization/Renderer/Renderer.cs @@ -0,0 +1,49 @@ +using SkiaSharp; + +namespace TagsCloudVisualization.Renderer; + +public class Renderer : IRenderer +{ + private readonly SKBitmap bitmap; + private readonly SKPaint paint; + + public Renderer(SKSize size) + { + bitmap = new SKBitmap((int)size.Width, (int)size.Height); + paint = new SKPaint + { + Color = SKColors.Black, + IsStroke = true, + TextSize = 24 + }; + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.LightGray); + } + + public void DrawRectangles(IEnumerable rectangles) + { + using var canvas = new SKCanvas(bitmap); + foreach (var rectangle in rectangles) + { + ValidateRectangle(rectangle); + canvas.DrawRect(rectangle, paint); + paint.Color = new SKColor((byte)(paint.Color.Red + 21), (byte)(paint.Color.Green + 43), + (byte)(paint.Color.Blue + 67)); + } + } + + private void ValidateRectangle(SKRect rectangle) + { + if (rectangle.Left < 0 || rectangle.Top < 0 || rectangle.Right > bitmap.Width || + rectangle.Bottom > bitmap.Height) + throw new ArgumentException("Rectangle is out of bounds"); + if (rectangle.Left >= rectangle.Right || rectangle.Top >= rectangle.Bottom) + throw new ArgumentException("Rectangle is invalid"); + } + + public SKData GetEncodedImage() + { + using var image = SKImage.FromBitmap(bitmap); + return image.Encode(SKEncodedImageFormat.Png, 100); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/Samples/cloud_10.png b/cs/TagsCloudVisualization/Samples/cloud_10.png new file mode 100644 index 000000000..6fe4be7f6 Binary files /dev/null and b/cs/TagsCloudVisualization/Samples/cloud_10.png differ diff --git a/cs/TagsCloudVisualization/Samples/cloud_100.png b/cs/TagsCloudVisualization/Samples/cloud_100.png new file mode 100644 index 000000000..159509c92 Binary files /dev/null and b/cs/TagsCloudVisualization/Samples/cloud_100.png differ diff --git a/cs/TagsCloudVisualization/Samples/cloud_50.png b/cs/TagsCloudVisualization/Samples/cloud_50.png new file mode 100644 index 000000000..f55023cb2 Binary files /dev/null and b/cs/TagsCloudVisualization/Samples/cloud_50.png differ diff --git a/cs/TagsCloudVisualization/TagsCloudVisualization.csproj b/cs/TagsCloudVisualization/TagsCloudVisualization.csproj new file mode 100644 index 000000000..a259cb7ba --- /dev/null +++ b/cs/TagsCloudVisualization/TagsCloudVisualization.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + false + + + + + + + + diff --git a/cs/TagsCloudVisualizationTests/CircularCloudLayouterTests.cs b/cs/TagsCloudVisualizationTests/CircularCloudLayouterTests.cs new file mode 100644 index 000000000..fe6f915d5 --- /dev/null +++ b/cs/TagsCloudVisualizationTests/CircularCloudLayouterTests.cs @@ -0,0 +1,112 @@ +using NUnit.Framework.Interfaces; +using TagsCloudVisualization.Layouter; +using TagsCloudVisualization.Renderer; + +namespace TagsCloudVisualizationTests; + +[TestFixture] +public class CircularCloudLayouterTests +{ + private CircularCloudLayouter layouter; + private static readonly SKPoint Center = new(500, 500); + private static readonly float Density = 0.65f; + + [SetUp] + public void SetUp() + { + layouter = new CircularCloudLayouter(Center); + } + + [TearDown] + public void TearDown() + { + if (TestContext.CurrentContext.Result.Outcome.Status != TestStatus.Failed) return; + SaveImage(); + } + + private void SaveImage() + { + var filename = "tests/layouter_" + TestContext.CurrentContext.Test.ID + ".png"; + + var renderer = new Renderer(new SKSize(Center.X * 2, Center.Y * 2)); + renderer.DrawRectangles(layouter.GetRectangles()); + var imageData = renderer.GetEncodedImage(); + Directory.CreateDirectory("tests"); + var path = Path.Combine(Directory.GetCurrentDirectory(), filename); + using var stream = new FileStream(path, FileMode.Create); + imageData.SaveTo(stream); + Console.WriteLine($"Tag cloud visualization saved to file {path}"); + } + + private IEnumerable GetRandomSizes(int count) + { + var random = new Random(); + for (var i = 0; i < count; i++) + { + var size = new SKSize(random.Next(10, 100), random.Next(10, 100)); + yield return size; + } + } + + [Test] + public void PutNextRectangle_ShouldThrowArgumentException_WhenSizeNotPositive() + { + Action action = () => layouter.PutNextRectangle(new SKSize(0, 100)); + action.Should().Throw(); + } + + [Test] + public void PutNextRectangle_ShouldReturnRectangle() + { + var rectangle = layouter.PutNextRectangle(new SKSize(100, 100)); + + rectangle.Should().BeEquivalentTo(new SKRect(Center.X - 50, Center.Y - 50, Center.X + 50, Center.Y + 50)); + } + + [Test] + public void PutNextRectangle_ShouldNotIntersectRectangles() + { + var rectangles = new List(); + for (var i = 0; i < 10; i++) rectangles.Add(layouter.PutNextRectangle(new SKSize(10, 10))); + + for (var i = 0; i < rectangles.Count - 1; i++) + for (var j = i + 1; j < rectangles.Count; j++) + rectangles[i].IntersectsWith(rectangles[j]).Should().BeFalse(); + } + + [Test] + [Repeat(3)] + public void PutNextRectangle_ShouldGenerateDenseLayout() + { + var sizes = GetRandomSizes(150); + var rectangles = sizes.Select(size => layouter.PutNextRectangle(size)).ToArray(); + var totalRectArea = rectangles.Sum(rect => rect.Width * rect.Height); + var boundingCircleRadius = rectangles.Max(DistanceToCenter); + var boundingCircleArea = Math.PI * boundingCircleRadius * boundingCircleRadius; + var density = totalRectArea / boundingCircleArea; + density.Should().BeGreaterOrEqualTo(Density); + } + + [Test] + [Repeat(3)] + public void PutNextRectangle_ShouldPlaceRectanglesInCircle() + { + var sizes = GetRandomSizes(150); + var rectangles = sizes.Select(size => layouter.PutNextRectangle(size)).ToArray(); + + + var presumedAverageSide = rectangles.Average(size => (size.Width + size.Height) / 2); + var totalAreaOfRectangles = rectangles.Sum(rect => rect.Width * rect.Height); + var circleRadius = Math.Sqrt(totalAreaOfRectangles / Density / Math.PI); + var expectedMaxDistanceFromCenter = circleRadius + presumedAverageSide / 2; + var maxDistanceFromCenter = (double)rectangles.Max(DistanceToCenter); + + maxDistanceFromCenter.Should().BeLessOrEqualTo(expectedMaxDistanceFromCenter); + } + + private static float DistanceToCenter(SKRect rect) + { + var rectCenter = new SKPoint(rect.MidX, rect.MidY); + return SKPoint.Distance(Center, rectCenter); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualizationTests/GlobalUsings.cs b/cs/TagsCloudVisualizationTests/GlobalUsings.cs new file mode 100644 index 000000000..c4f7a5819 --- /dev/null +++ b/cs/TagsCloudVisualizationTests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using NUnit.Framework; +global using FluentAssertions; +global using SkiaSharp; \ No newline at end of file diff --git a/cs/TagsCloudVisualizationTests/RendererTests.cs b/cs/TagsCloudVisualizationTests/RendererTests.cs new file mode 100644 index 000000000..0fdf4a76e --- /dev/null +++ b/cs/TagsCloudVisualizationTests/RendererTests.cs @@ -0,0 +1,66 @@ +using NUnit.Framework.Interfaces; +using TagsCloudVisualization.Renderer; + +namespace TagsCloudVisualizationTests; + +[TestFixture] +public class RendererTests +{ + private const string DefaultFileName = "image.png"; + private Renderer renderer; + + [SetUp] + public void SetUp() + { + renderer = new Renderer(new SKSize(100, 100)); + } + + [TearDown] + public void TearDown() + { + if (File.Exists(DefaultFileName)) + File.Delete(DefaultFileName); + + if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed) + { + SaveImage(); + } + } + + private void SaveImage() + { + var filename = "tests/renderer_" + TestContext.CurrentContext.Test.ID + ".png"; + Directory.CreateDirectory("tests"); + var path = Path.Combine(Directory.GetCurrentDirectory(), filename); + using var stream = new FileStream(path, FileMode.Create); + var imageData = renderer.GetEncodedImage(); + imageData.SaveTo(stream); + Console.WriteLine($"Attempted to save result to file {path}"); + } + + + [Test] + public void CreateImage_ShouldCreateImage() + { + var image = renderer.GetEncodedImage(); + image.Should().NotBeNull(); + } + + [TestCase(0, 200)] + [TestCase(-1, 50)] + public void CreateRectangles_ShouldThrowException_WhenRectanglesAreOutOfBounds(int topLeft, int bottomRight) + { + var action = () => renderer.DrawRectangles([new SKRect(topLeft, topLeft, bottomRight, bottomRight)]); + action.Should().ThrowExactly(); + } + + [TestCase(0, 0, 0, 0)] + [TestCase(50, 50, 0, 0)] + [TestCase(50, 0, 50, 0)] + public void CreateRectangles_ShouldThrowException_WhenRectanglesAreInvalid(int left, int top, + int right, int bottom) + { + Assert.Throws(() => + renderer.DrawRectangles([new SKRect(left, top, right, bottom)])); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualizationTests/TagsCloudVisualizationTests.csproj b/cs/TagsCloudVisualizationTests/TagsCloudVisualizationTests.csproj new file mode 100644 index 000000000..dc0136100 --- /dev/null +++ b/cs/TagsCloudVisualizationTests/TagsCloudVisualizationTests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + diff --git a/cs/tdd.sln b/cs/tdd.sln index c8f523d63..59f798141 100644 --- a/cs/tdd.sln +++ b/cs/tdd.sln @@ -7,6 +7,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BowlingGame", "BowlingGame\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples", "Samples\Samples.csproj", "{B5108E20-2ACF-4ED9-84FE-2A718050FC94}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagsCloudVisualization", "TagsCloudVisualization\TagsCloudVisualization.csproj", "{DF0CEB82-DCE8-415C-B6FE-23FB484BA691}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagsCloudVisualizationTests", "TagsCloudVisualizationTests\TagsCloudVisualizationTests.csproj", "{9410F67F-D284-45D4-9725-3540E986D2E4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +25,14 @@ Global {B5108E20-2ACF-4ED9-84FE-2A718050FC94}.Debug|Any CPU.Build.0 = Debug|Any CPU {B5108E20-2ACF-4ED9-84FE-2A718050FC94}.Release|Any CPU.ActiveCfg = Release|Any CPU {B5108E20-2ACF-4ED9-84FE-2A718050FC94}.Release|Any CPU.Build.0 = Release|Any CPU + {DF0CEB82-DCE8-415C-B6FE-23FB484BA691}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF0CEB82-DCE8-415C-B6FE-23FB484BA691}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF0CEB82-DCE8-415C-B6FE-23FB484BA691}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF0CEB82-DCE8-415C-B6FE-23FB484BA691}.Release|Any CPU.Build.0 = Release|Any CPU + {9410F67F-D284-45D4-9725-3540E986D2E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9410F67F-D284-45D4-9725-3540E986D2E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9410F67F-D284-45D4-9725-3540E986D2E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9410F67F-D284-45D4-9725-3540E986D2E4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/cs/tdd.sln.DotSettings b/cs/tdd.sln.DotSettings index 135b83ecb..229f449d2 100644 --- a/cs/tdd.sln.DotSettings +++ b/cs/tdd.sln.DotSettings @@ -1,6 +1,9 @@  <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb_AaBb" /> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"><ElementKinds><Kind Name="NAMESPACE" /><Kind Name="CLASS" /><Kind Name="STRUCT" /><Kind Name="ENUM" /><Kind Name="DELEGATE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy> + True True True Imported 10.10.2016