diff --git a/cs/TagsCloudVisualization/CircularCloudLayouter.cs b/cs/TagsCloudVisualization/CircularCloudLayouter.cs new file mode 100644 index 000000000..c991f6030 --- /dev/null +++ b/cs/TagsCloudVisualization/CircularCloudLayouter.cs @@ -0,0 +1,39 @@ +using SkiaSharp; + +namespace TagsCloudVisualization; + +public class CircularCloudLayouter(SKPoint center) : ICloudLayouter +{ + private const double OptimalRadius = 1; + private const double OptimalAngleOffset = 0.5; + + private readonly List rectangles = new(); + private readonly SpiralPointsGenerator pointsGenerator = new(center, OptimalRadius, OptimalAngleOffset); + + public SKPoint Center => center; + public IEnumerable Rectangles => rectangles; + + public SKRect PutNextRectangle(SKSize rectangleSize) + { + while (true) + { + var rectanglePosition = pointsGenerator.GetNextPoint(); + var rectangle = CreateRectangleWithCenter(rectanglePosition, rectangleSize); + + if (rectangles.Any(rectangle.IntersectsWith)) continue; + + rectangles.Add(rectangle); + + return rectangle; + } + } + + private static SKRect CreateRectangleWithCenter(SKPoint center, SKSize rectangleSize) + { + var left = center.X - rectangleSize.Width / 2; + var top = center.Y - rectangleSize.Height / 2; + + return new SKRect(left, top, left + rectangleSize.Width, top + rectangleSize.Height); + } +} + diff --git a/cs/TagsCloudVisualization/ICloudLayouter.cs b/cs/TagsCloudVisualization/ICloudLayouter.cs new file mode 100644 index 000000000..ba35b2c80 --- /dev/null +++ b/cs/TagsCloudVisualization/ICloudLayouter.cs @@ -0,0 +1,8 @@ +using SkiaSharp; + +namespace TagsCloudVisualization; + +public interface ICloudLayouter +{ + public SKRect PutNextRectangle(SKSize rectangleSize); +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/IPointsGenerator.cs b/cs/TagsCloudVisualization/IPointsGenerator.cs new file mode 100644 index 000000000..592199675 --- /dev/null +++ b/cs/TagsCloudVisualization/IPointsGenerator.cs @@ -0,0 +1,8 @@ +using SkiaSharp; + +namespace TagsCloudVisualization; + +public interface IPointsGenerator +{ + public SKPoint GetNextPoint(); +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/Program.cs b/cs/TagsCloudVisualization/Program.cs new file mode 100644 index 000000000..8dbd77852 --- /dev/null +++ b/cs/TagsCloudVisualization/Program.cs @@ -0,0 +1,30 @@ +using SkiaSharp; + +namespace TagsCloudVisualization; + +class Program +{ + private const int ImageWidth = 2560; + private const int ImageHeight = 1440; + + private const int NumberOfRectangles = 50; + private const int MinRectangleSize = 10; + private const int MaxRectangleSize = 50; + + private const string ImageDirectory = "../../../imgs"; + public static void Main(string[] args) + { + var center = new SKPoint(ImageWidth / 2f, ImageHeight / 2f); + var cloudLayouter = new CircularCloudLayouter(center); + var rectangles = Enumerable + .Range(0, NumberOfRectangles) + .Select(_ => + cloudLayouter.PutNextRectangle(Random.Shared.NextSkSize(MinRectangleSize, MaxRectangleSize))) + .ToList(); + + var visualizer = new TagCloudVisualizer(ImageWidth, ImageHeight); + var bitmap = visualizer.Visualize(rectangles); + + TagCloudSaver.SaveAsPng(bitmap, Path.Combine(ImageDirectory,$"{NumberOfRectangles}_TagCloud")); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/README.md b/cs/TagsCloudVisualization/README.md new file mode 100644 index 000000000..402aef70a --- /dev/null +++ b/cs/TagsCloudVisualization/README.md @@ -0,0 +1,8 @@ +### 50 прямоугольников + + +### 100 прямоугольников + + +### 1000 прямоугольников + diff --git a/cs/TagsCloudVisualization/RandomExtension.cs b/cs/TagsCloudVisualization/RandomExtension.cs new file mode 100644 index 000000000..3af933c67 --- /dev/null +++ b/cs/TagsCloudVisualization/RandomExtension.cs @@ -0,0 +1,19 @@ +using SkiaSharp; + +namespace TagsCloudVisualization; + +public static class RandomExtension +{ + public static SKSize NextSkSize(this Random random, int minValue, int maxValue) + { + if (minValue <= 0) + throw new ArgumentOutOfRangeException(nameof(minValue), "minValue must be greater than 0"); + if (maxValue < minValue) + throw new ArgumentOutOfRangeException(nameof(maxValue), "maxValue must be greater than minValue"); + + return new SKSize(random.Next(minValue, maxValue), random.Next(minValue, maxValue)); + } + + public static SKPoint NextSkPoint(this Random random, int minValue, int maxValue) => + new (random.Next(minValue, maxValue), random.Next(minValue, maxValue)); +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/SpiralPointsGenerator.cs b/cs/TagsCloudVisualization/SpiralPointsGenerator.cs new file mode 100644 index 000000000..0c9796d07 --- /dev/null +++ b/cs/TagsCloudVisualization/SpiralPointsGenerator.cs @@ -0,0 +1,41 @@ +using SkiaSharp; + +namespace TagsCloudVisualization; + +public class SpiralPointsGenerator : IPointsGenerator +{ + private readonly double angleOffset; + private readonly double radius; + private readonly SKPoint start; + private double angle; + + public SpiralPointsGenerator(SKPoint start, double radius, double angleOffset) + { + if (radius <= 0) + throw new ArgumentException("radius must be greater than 0"); + if (angleOffset <= 0) + throw new ArgumentException("angleOffset must be greater than 0"); + + this.angleOffset = angleOffset * Math.PI / 180; + this.radius = radius; + this.start = start; + } + + public SKPoint GetNextPoint() + { + var nextPoint = GetPointByPolarCords(); + angle += angleOffset; + return nextPoint; + } + + private SKPoint GetPointByPolarCords() + { + var offsetPerRadian = radius / (2 * Math.PI); + var radiusVector = offsetPerRadian * angle; + + var x = (int)Math.Round(radiusVector * Math.Cos(angle) + start.X); + var y = (int)Math.Round(radiusVector * Math.Sin(angle) + start.Y); + + return new SKPoint(x, y); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/TagCloudSaver.cs b/cs/TagsCloudVisualization/TagCloudSaver.cs new file mode 100644 index 000000000..f0af6b05a --- /dev/null +++ b/cs/TagsCloudVisualization/TagCloudSaver.cs @@ -0,0 +1,15 @@ +using SkiaSharp; + +namespace TagsCloudVisualization; + +public static class TagCloudSaver +{ + private const int ImageQuality = 80; + + public static void SaveAsPng(SKBitmap bitmap, string filePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + using var file = File.OpenWrite($"{filePath}.png"); + bitmap.Encode(SKEncodedImageFormat.Png, ImageQuality).SaveTo(file); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/TagCloudVisualizer.cs b/cs/TagsCloudVisualization/TagCloudVisualizer.cs new file mode 100644 index 000000000..8d161a0e6 --- /dev/null +++ b/cs/TagsCloudVisualization/TagCloudVisualizer.cs @@ -0,0 +1,42 @@ +using SkiaSharp; + + +namespace TagsCloudVisualization; + +public class TagCloudVisualizer(int width, int height) +{ + public SKBitmap Visualize(List rectangles) + { + var layoutSize = GetLayoutSize(rectangles); + var bimapWidth = Math.Max(width, layoutSize.Width) * 2 ; + var bimapHeight = Math.Max(height, layoutSize.Height) * 2; + var bitmap = new SKBitmap(bimapWidth, bimapHeight); + var canvas = new SKCanvas(bitmap); + var paint = new SKPaint + { + Color = SKColors.Black, + Style = SKPaintStyle.Stroke + }; + + canvas.Clear(SKColors.White); + + var xOffset = bitmap.Width / 2f - rectangles.First().Location.X; + var yOffset = bitmap.Height / 2f - rectangles.First().Location.Y; + + foreach (var rectangle in rectangles) + { + rectangle.Offset(xOffset, yOffset); + canvas.DrawRect(rectangle, paint); + } + + return bitmap; + } + + private SKSizeI GetLayoutSize(List rectangles) + { + var layoutWidth = rectangles.Max(r => r.Right) - rectangles.Min(r => r.Left); + var layoutHeight = rectangles.Max(r => r.Top) - rectangles.Min(r => r.Bottom); + + return new SKSize(layoutWidth, layoutHeight).ToSizeI(); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/TagsCloudVisualization.csproj b/cs/TagsCloudVisualization/TagsCloudVisualization.csproj new file mode 100644 index 000000000..f38b92ddd --- /dev/null +++ b/cs/TagsCloudVisualization/TagsCloudVisualization.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/cs/TagsCloudVisualization/imgs/1000_TagCloud.png b/cs/TagsCloudVisualization/imgs/1000_TagCloud.png new file mode 100644 index 000000000..d89ef8064 Binary files /dev/null and b/cs/TagsCloudVisualization/imgs/1000_TagCloud.png differ diff --git a/cs/TagsCloudVisualization/imgs/100_TagCloud.png b/cs/TagsCloudVisualization/imgs/100_TagCloud.png new file mode 100644 index 000000000..63f34a33a Binary files /dev/null and b/cs/TagsCloudVisualization/imgs/100_TagCloud.png differ diff --git a/cs/TagsCloudVisualization/imgs/50_TagCloud.png b/cs/TagsCloudVisualization/imgs/50_TagCloud.png new file mode 100644 index 000000000..cb416de4e Binary files /dev/null and b/cs/TagsCloudVisualization/imgs/50_TagCloud.png differ diff --git a/cs/TagsCloudVisualizationTests/CircularCloudLayouterTest.cs b/cs/TagsCloudVisualizationTests/CircularCloudLayouterTest.cs new file mode 100644 index 000000000..59524aa80 --- /dev/null +++ b/cs/TagsCloudVisualizationTests/CircularCloudLayouterTest.cs @@ -0,0 +1,110 @@ +using FluentAssertions; +using NUnit.Framework.Interfaces; +using SkiaSharp; +using TagsCloudVisualization; + +namespace TagsCloudVisualizationTests; + +[TestFixture] +[TestOf(typeof(CircularCloudLayouter))] +public class CircularCloudLayouterTest +{ + private const string ImagesDirectory = "../../../failedTests"; + private CircularCloudLayouter circularCloudLayouter; + + [SetUp] + public void Setup() + { + circularCloudLayouter = new CircularCloudLayouter(new SKPoint(0, 0)); + } + + [TearDown] + public void TearDown() + { + var currentContext = TestContext.CurrentContext; + if (currentContext.Result.Outcome.Status != TestStatus.Failed) return; + + var layoutSize = GetLayoutSize(circularCloudLayouter.Rectangles.ToList()); + var visualizer = new TagCloudVisualizer((int)layoutSize.Width, (int)layoutSize.Height); + var bitmap = visualizer.Visualize(circularCloudLayouter.Rectangles.ToList()); + + var pathToFile = Path.Combine(ImagesDirectory, currentContext.Test.Name); + TagCloudSaver.SaveAsPng(bitmap, pathToFile); + + TestContext.Out.WriteLine($"Tag cloud visualization saved to file {pathToFile}.png"); + } + + [Test] + public void PutNextRectangle_ShouldReturnRectangleAtCenter_WhenFirstInvoked() + { + var rectSize = Random.Shared.NextSkSize(1, int.MaxValue); + + var actualRect = circularCloudLayouter.PutNextRectangle(rectSize); + + var expectedRect = new SKRect( + circularCloudLayouter.Center.X - rectSize.Width / 2, + circularCloudLayouter.Center.Y - rectSize.Height / 2, + circularCloudLayouter.Center.X + rectSize.Width / 2, + circularCloudLayouter.Center.Y + rectSize.Height / 2); + + actualRect.Should().BeEquivalentTo(expectedRect); + } + + [Test] + public void PutNextRectangle_ShouldReturnRectangle_WithCorrectSize() + { + var rectangleSize = Random.Shared.NextSkSize(1, int.MaxValue); + + var actualRectangle = circularCloudLayouter.PutNextRectangle(rectangleSize); + + actualRectangle.Size.Should().Be(rectangleSize); + } + + [Test] + public void PutNextRectangle_ShouldReturnRectangles_WithoutIntersections() + { + var numberOfRectangles = Random.Shared.Next(100, 300); + + var rectangles = Enumerable + .Range(0, numberOfRectangles) + .Select(_ => circularCloudLayouter.PutNextRectangle(Random.Shared.NextSkSize(10, 27))) + .ToList(); + + bool IsIntersectionBetweenRectangles(SKRect rect) => + rectangles.Any(otherRect => rect != otherRect && rect.IntersectsWith(otherRect)); + + rectangles.Any(IsIntersectionBetweenRectangles) + .Should().BeFalse(); + } + + [Test] + public void GeneratedLayout_ShouldHaveHighTightnessAndShapeOfCircularCloud() + { + const double eps = 0.35; + var rectangles = PutRandomRectanglesInLayouter(Random.Shared.Next(500, 1000)); + var layoutSize = GetLayoutSize(rectangles); + + var diameterOfCircle = Math.Max(layoutSize.Width, layoutSize.Height); + var areaOfCircle = Math.PI * Math.Pow(diameterOfCircle, 2) / 4; + var areaOfRectangles = (double)rectangles + .Select(r => r.Height * r.Width) + .Sum(); + var areaRatio = areaOfCircle / areaOfRectangles; + + areaRatio.Should().BeApproximately(1, eps); + } + + private List PutRandomRectanglesInLayouter(int numberOfRectangles) => + Enumerable + .Range(0, numberOfRectangles) + .Select(_ => circularCloudLayouter.PutNextRectangle(Random.Shared.NextSkSize(10, 27))) + .ToList(); + + private SKSize GetLayoutSize(List rectangles) + { + var layoutWidth = rectangles.Max(r => r.Right) - rectangles.Min(r => r.Left); + var layoutHeight = rectangles.Max(r => r.Top) - rectangles.Min(r => r.Bottom); + + return new SKSize(layoutWidth, layoutHeight); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualizationTests/RandomExtensionTest.cs b/cs/TagsCloudVisualizationTests/RandomExtensionTest.cs new file mode 100644 index 000000000..a3704b28f --- /dev/null +++ b/cs/TagsCloudVisualizationTests/RandomExtensionTest.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using SkiaSharp; +using TagsCloudVisualization; + +namespace TagsCloudVisualizationTests; + +[TestFixture] +[TestOf(typeof(RandomExtension))] +public class RandomExtensionTest +{ + private const int Seed = 123456789; + private readonly Random random = new(Seed); + + [TestCase(0, 5, TestName = "MinValue is zero")] + [TestCase(-1, 10, TestName = "MinValue is negative")] + [TestCase(50, 20, TestName = "MinValue is greater than MaxValue")] + public void NextSkSize_ShouldThrowArgumentOutOfRangeException_WithInvalidParams(int minValue, int maxValue) + { + Action act = () => random.NextSkSize(minValue, maxValue); + + act.Should().Throw(); + } + + [Test] + public void NextSkSize_ShouldReturnExpectedNextSize() + { + var seed = random.Next(); + var testRandom = new Random(seed); + var expectedRandom = new Random(seed); + + var actualSize = testRandom.NextSkSize(1, int.MaxValue); + var expectedSize = new SKSize( + expectedRandom.Next(1, int.MaxValue), + expectedRandom.Next(1, int.MaxValue)); + + actualSize.Should().BeEquivalentTo(expectedSize); + } + + [Test] + public void NextSkPoint_ShouldReturnExpectedNextPoint() + { + var seed = random.Next(); + var pointRandomizer = new Random(seed); + var expectedRandomizer = new Random(seed); + + var actualPoint = pointRandomizer.NextSkPoint(int.MinValue, int.MaxValue); + var expectedPoint = new SKPoint( + expectedRandomizer.Next(int.MinValue, int.MaxValue), + expectedRandomizer.Next(int.MinValue, int.MaxValue)); + + actualPoint.Should().BeEquivalentTo(expectedPoint); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualizationTests/SpiralPointsGeneratorTest.cs b/cs/TagsCloudVisualizationTests/SpiralPointsGeneratorTest.cs new file mode 100644 index 000000000..f131c13bb --- /dev/null +++ b/cs/TagsCloudVisualizationTests/SpiralPointsGeneratorTest.cs @@ -0,0 +1,43 @@ +using SkiaSharp; +using FluentAssertions; +using TagsCloudVisualization; + +namespace TagsCloudVisualizationTests; + +[TestFixture] +[TestOf(typeof(SpiralPointsGenerator))] +public class SpiralPointsGeneratorTest +{ + private const string RadiusErrorMessage = "radius must be greater than 0"; + private const string AngleOffsetErrorMessage = "angleOffset must be greater than 0"; + + [TestCase(-1, 10, RadiusErrorMessage, TestName = "radius is negative")] + [TestCase(0, 3, RadiusErrorMessage, TestName = "radius is zero")] + [TestCase(5, -11, AngleOffsetErrorMessage, TestName = "angleOffset is negative")] + [TestCase(100, 0, AngleOffsetErrorMessage, TestName = "angleOffset is zero")] + public void SpiralPointsGenerator_ShouldThrowArgumentException_WithInvalidParams(double radius, + double angleOffset, string msg) + { + var start = new SKPoint(0, 0); + Action act = () => new SpiralPointsGenerator(start, radius, angleOffset); + + act.Should().Throw().WithMessage(msg); + } + + [Test] + public void SpiralPointsGenerator_ShouldReturnExactListOfFiveFirstPoints_WithSpecialParams() + { + var spiralPointsGenerator = new SpiralPointsGenerator(new SKPoint(0, 0), 2, 360); + var expectedListOfPoints = new List + { + new(0, 0), new(2, 0), new(4, 0), new(6, 0), new(8, 0) + }; + + var actualListOfPoints = Enumerable + .Range(0, 5) + .Select(_ => spiralPointsGenerator.GetNextPoint()) + .ToList(); + + actualListOfPoints.Should().BeEquivalentTo(expectedListOfPoints); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualizationTests/TagsCloudVisualizationTests.csproj b/cs/TagsCloudVisualizationTests/TagsCloudVisualizationTests.csproj new file mode 100644 index 000000000..f7fdda21d --- /dev/null +++ b/cs/TagsCloudVisualizationTests/TagsCloudVisualizationTests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/cs/tdd.sln b/cs/tdd.sln index c8f523d63..e5c30e2af 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", "{8EEFEA14-2006-43BE-9D3B-4BBCD6EC4BE9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagsCloudVisualizationTests", "TagsCloudVisualizationTests\TagsCloudVisualizationTests.csproj", "{DBDE5346-1109-4268-8E30-21EB9B7A2F43}" +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 + {8EEFEA14-2006-43BE-9D3B-4BBCD6EC4BE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EEFEA14-2006-43BE-9D3B-4BBCD6EC4BE9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EEFEA14-2006-43BE-9D3B-4BBCD6EC4BE9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EEFEA14-2006-43BE-9D3B-4BBCD6EC4BE9}.Release|Any CPU.Build.0 = Release|Any CPU + {DBDE5346-1109-4268-8E30-21EB9B7A2F43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBDE5346-1109-4268-8E30-21EB9B7A2F43}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBDE5346-1109-4268-8E30-21EB9B7A2F43}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBDE5346-1109-4268-8E30-21EB9B7A2F43}.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