diff --git a/cs/TagsCloudVisualization/ArchimedeanSpiral.cs b/cs/TagsCloudVisualization/ArchimedeanSpiral.cs new file mode 100644 index 000000000..2b72be7db --- /dev/null +++ b/cs/TagsCloudVisualization/ArchimedeanSpiral.cs @@ -0,0 +1,19 @@ +using System.Drawing; + +namespace TagsCloudVisualization +{ + public class ArchimedeanSpiral(Point centralPoint, double scale = 1) + { + private double angle { get; set; } + private const double DeltaAngle = Math.PI / 180; + + public Point GetNextPoint() + { + var newX = (int)(centralPoint.X + scale * angle * Math.Cos(angle)); + var newY = (int)(centralPoint.Y + scale * angle * Math.Sin(angle)); + angle += DeltaAngle; + + return new Point(newX, newY); + } + } +} diff --git a/cs/TagsCloudVisualization/CircularCloudLayouter.cs b/cs/TagsCloudVisualization/CircularCloudLayouter.cs new file mode 100644 index 000000000..bc65f56ff --- /dev/null +++ b/cs/TagsCloudVisualization/CircularCloudLayouter.cs @@ -0,0 +1,85 @@ +using System.Drawing; + +namespace TagsCloudVisualization +{ + public class CircularCloudLayouter + { + private readonly ArchimedeanSpiral spiral; + private readonly List rectangles = []; + private readonly Point centerPoint; + + public IReadOnlyList Rectangles => rectangles.AsReadOnly(); + + public CircularCloudLayouter(Point centerPoint) + { + this.centerPoint = centerPoint; + spiral = new ArchimedeanSpiral(centerPoint); + } + + public Rectangle PutNextRectangle(Size rectangleSize) + { + if (rectangleSize.Height <= 0 || rectangleSize.Width <= 0) + throw new ArgumentException( + $"rectangleSize with zero or negative height or width is prohibited!", + nameof(rectangleSize) + ); + while (true) + { + var nextPoint = spiral.GetNextPoint(); + var newPoint = new Point(nextPoint.X - rectangleSize.Width / 2, nextPoint.Y - rectangleSize.Height / 2); + var rectangle = new Rectangle(newPoint, rectangleSize); + if (IsIntersectsWithOthers(rectangle)) continue; + rectangle = GetCloserToCenterRectangle(rectangle); + rectangles.Add(rectangle); + break; + } + + return rectangles[^1]; + } + + private bool IsIntersectsWithOthers(Rectangle rectangle) => + rectangles.Any(x => x.IntersectsWith(rectangle)); + + private Rectangle GetCloserToCenterRectangle(Rectangle rectangle) + { + var directions = GetDirection(rectangle); + foreach (var direction in directions) + { + var newRectangle = GetMovedRectangle(rectangle, direction.X, direction.Y); + while (!IsIntersectsWithOthers(newRectangle)) + { + if (centerPoint.X - newRectangle.Size.Width / 2 == newRectangle.X + || centerPoint.Y - newRectangle.Size.Height / 2 == newRectangle.Y) + break; + rectangle = newRectangle; + newRectangle = GetMovedRectangle(rectangle, direction.X, direction.Y); + } + } + + return rectangle; + } + + private List<(int X, int Y)> GetDirection(Rectangle rectangle) + { + var horizontalDiffer = centerPoint.X - rectangle.Size.Width / 2 - rectangle.X; + var verticalDiffer = centerPoint.Y - rectangle.Size.Height / 2 - rectangle.Y; + var directions = new List<(int X, int Y)>(); + if (horizontalDiffer != 0 && verticalDiffer != 0) + directions.Add((horizontalDiffer > 0 ? 1 : -1, verticalDiffer > 0 ? 1 : -1)); + if (horizontalDiffer != 0) + directions.Add((horizontalDiffer > 0 ? 1 : -1, 0)); + if (verticalDiffer != 0) + directions.Add((0, verticalDiffer > 0 ? 1 : -1)); + return directions; + } + + private static Rectangle GetMovedRectangle(Rectangle rectangle, int xDelta, int yDelta) => + new( + new Point( + rectangle.X + xDelta, + rectangle.Y + yDelta + ), + rectangle.Size + ); + } +} diff --git a/cs/TagsCloudVisualization/Drawer.cs b/cs/TagsCloudVisualization/Drawer.cs new file mode 100644 index 000000000..60c7ab3d9 --- /dev/null +++ b/cs/TagsCloudVisualization/Drawer.cs @@ -0,0 +1,25 @@ +using System.Drawing; + +namespace TagsCloudVisualization +{ + public class Drawer + { + public static Image GetImage(Size size, IEnumerable rectangles) + { + if (size.Width <= 0 || size.Height <= 0) + throw new ArgumentException("size width and height should be positive", "size"); + var image = new Bitmap(size.Width, size.Height); + using (var gr = Graphics.FromImage(image)) + { + gr.Clear(Color.Black); + using (var brush = new SolidBrush(Color.White)) + { + foreach (var rectangle in rectangles) + gr.FillRectangle(brush, rectangle); + } + } + + return image; + } + } +} diff --git a/cs/TagsCloudVisualization/Drawings/CloudTag_DecreasingSize.png b/cs/TagsCloudVisualization/Drawings/CloudTag_DecreasingSize.png new file mode 100644 index 000000000..3abcb75b9 Binary files /dev/null and b/cs/TagsCloudVisualization/Drawings/CloudTag_DecreasingSize.png differ diff --git a/cs/TagsCloudVisualization/Drawings/CloudTag_RandomSize.png b/cs/TagsCloudVisualization/Drawings/CloudTag_RandomSize.png new file mode 100644 index 000000000..eb89584d6 Binary files /dev/null and b/cs/TagsCloudVisualization/Drawings/CloudTag_RandomSize.png differ diff --git a/cs/TagsCloudVisualization/Drawings/CloudTag_SameSize.png b/cs/TagsCloudVisualization/Drawings/CloudTag_SameSize.png new file mode 100644 index 000000000..448c38036 Binary files /dev/null and b/cs/TagsCloudVisualization/Drawings/CloudTag_SameSize.png differ diff --git a/cs/TagsCloudVisualization/Program.cs b/cs/TagsCloudVisualization/Program.cs new file mode 100644 index 000000000..9016fad78 --- /dev/null +++ b/cs/TagsCloudVisualization/Program.cs @@ -0,0 +1,54 @@ +using System.Drawing; + +namespace TagsCloudVisualization +{ + internal class Program + { + private static void Main() + { + var decImage = DrawDecreasingSize(); + var rndImage = DrawRandomSize(); + var sameImage = DrawSameSize(); + + decImage.Save("CloudTag_DecreasingSize.png"); + rndImage.Save("CloudTag_RandomSize.png"); + sameImage.Save("CloudTag_SameSize.png"); + } + + private static Image DrawDecreasingSize() + { + var layouter = new CircularCloudLayouter(new Point(340, 340)); + layouter.PutNextRectangle(new Size(200, 80)); + + for (var i = 0; i < 20; i++) + layouter.PutNextRectangle(new Size(80, 40)); + + for (var i = 0; i < 200; i++) + layouter.PutNextRectangle(new Size(40, 20)); + + return Drawer.GetImage(new Size(340 * 2, 340 * 2), layouter.Rectangles); + } + + private static Image DrawSameSize() + { + var layouter = new CircularCloudLayouter(new Point(340, 340)); + for (var i = 0; i < 200; i++) + layouter.PutNextRectangle(new Size(50, 25)); + + return Drawer.GetImage(new Size(340 * 2, 340 * 2), layouter.Rectangles); + } + + private static Image DrawRandomSize() + { + var layouter = new CircularCloudLayouter(new Point(340, 340)); + var rnd = new Random(); + for (var i = 0; i < 200; i++) + { + var width = rnd.Next(30, 60); + layouter.PutNextRectangle(new Size(width, rnd.Next(width / 2 - 10, width / 2))); + } + + return Drawer.GetImage(new Size(340 * 2, 340 * 2), layouter.Rectangles); + } + } +} diff --git a/cs/TagsCloudVisualization/README.md b/cs/TagsCloudVisualization/README.md new file mode 100644 index 000000000..206add722 --- /dev/null +++ b/cs/TagsCloudVisualization/README.md @@ -0,0 +1,10 @@ +# Примеры работы программы: + +### Облако тегов уменьшающихся размеров тегов +![CloudTag_DecreasingSize.png](Drawings%2FCloudTag_DecreasingSize.png) + +### Облакр тегов рандомного размера +![CloudTag_RandomSize.png](Drawings%2FCloudTag_RandomSize.png) + +### Облако тегов одинаковых размеров +![CloudTag_SameSize.png](Drawings%2FCloudTag_SameSize.png) diff --git a/cs/TagsCloudVisualization/TagsCloudVisualization.csproj b/cs/TagsCloudVisualization/TagsCloudVisualization.csproj new file mode 100644 index 000000000..6608fcd4c --- /dev/null +++ b/cs/TagsCloudVisualization/TagsCloudVisualization.csproj @@ -0,0 +1,15 @@ + + + + WinExe + net8.0 + enable + enable + + + + + + + + diff --git a/cs/TagsCloudVisualizationTests/ArchimedeanSpiral_Should.cs b/cs/TagsCloudVisualizationTests/ArchimedeanSpiral_Should.cs new file mode 100644 index 000000000..cfca29644 --- /dev/null +++ b/cs/TagsCloudVisualizationTests/ArchimedeanSpiral_Should.cs @@ -0,0 +1,30 @@ +using System.Drawing; +using FluentAssertions; +using NUnit.Framework; +using TagsCloudVisualization; + + +namespace TagsCloudVisualizationTests +{ + [TestFixture] + public class ArchimedeanSpiral_Should + { + [TestCaseSource(typeof(TestDataArchimedeanSpiral), nameof(TestDataArchimedeanSpiral.Different_CenterPoints))] + public void ReturnCenterPoint_WhenFirstTime_GetNextPoint(Point point) + { + var spiral = new ArchimedeanSpiral(point); + spiral.GetNextPoint().Should().BeEquivalentTo(point); + } + + [TestCaseSource(typeof(TestDataArchimedeanSpiral), + nameof(TestDataArchimedeanSpiral.DifferentIterationsAdded_ExpectedPoints))] + public void ReturnsCorrectPoint_When(int iterations, Point expectedPoint) + { + var spiral = new ArchimedeanSpiral(new Point()); + for (var i = 0; i < iterations; i++) + spiral.GetNextPoint(); + + spiral.GetNextPoint().Should().BeEquivalentTo(expectedPoint); + } + } +} diff --git a/cs/TagsCloudVisualizationTests/CircularCloudLayouter_Should.cs b/cs/TagsCloudVisualizationTests/CircularCloudLayouter_Should.cs new file mode 100644 index 000000000..7e97b7769 --- /dev/null +++ b/cs/TagsCloudVisualizationTests/CircularCloudLayouter_Should.cs @@ -0,0 +1,112 @@ +using System.Drawing; +using System.Reflection; +using FluentAssertions; +using NUnit.Framework; +using NUnit.Framework.Interfaces; +using TagsCloudVisualization; + +namespace TagsCloudVisualizationTests +{ + class CircularCloudLayouter_Should + { + private static CircularCloudLayouter? layouter; + + private string imagePath = + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + "\\FailedLayout.png"; + + [TearDown] + public void TagCloudVisualizerCircularCloudLayouterTearDown() + { + if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed && layouter is not null) + { + var failImage = Drawer.GetImage(new Size(1920, 1080), layouter.Rectangles); + failImage.Save(imagePath); + Console.WriteLine($"Tag cloud visualization saved to file <{imagePath}>"); + } + + layouter = null; + } + + [TestCaseSource(typeof(TestDataCircularCloudLayouter), + nameof(TestDataCircularCloudLayouter.ZeroOrLessHeightOrWidth_Size))] + public void Throw_WhenPutNewRectangle_WidthOrHeightLessEqualsZero(Size size) + { + var action = new Action(() => new CircularCloudLayouter(new Point()).PutNextRectangle(size)); + action.Should().Throw() + .Which.Message.Should().Contain("zero or negative height or width"); + } + + [Test] + public void RectanglesEmpty_AfterCreation() + { + layouter = new CircularCloudLayouter(new Point()); + layouter.Rectangles.Should().BeEmpty(); + } + + [TestCaseSource(typeof(TestDataArchimedeanSpiral), nameof(TestDataArchimedeanSpiral.Different_CenterPoints))] + public void Add_FirstRectangle_ToCenter(Point center) + { + layouter = new CircularCloudLayouter(center); + layouter.PutNextRectangle(new Size(10, 2)); + layouter.Rectangles.Should().HaveCount(1) + .And.BeEquivalentTo(new Rectangle( + new Point(center.X - 10 / 2, center.Y - 2 / 2), new Size(10, 2))); + } + + [TestCaseSource(typeof(TestDataArchimedeanSpiral), nameof(TestDataArchimedeanSpiral.Different_CenterPoints))] + public void AddSeveralRectangles_Correctly(Point centerPoint) + { + var amount = 25; + layouter = CreateLayouter_With_SeveralRectangles(amount, centerPoint); + layouter.Rectangles.Should().HaveCount(amount); + } + + [TestCaseSource(typeof(TestDataArchimedeanSpiral), nameof(TestDataArchimedeanSpiral.Different_CenterPoints))] + public void AddSeveralRectangles_DoNotIntersect(Point centerPoint) + { + layouter = CreateLayouter_With_SeveralRectangles(25, centerPoint); + var rectangles = layouter.Rectangles; + for (var i = 1; i < rectangles.Count; i++) + rectangles.Skip(i).All(x => !rectangles[i - 1].IntersectsWith(x)).Should().Be(true); + } + + [Test] + public void DensityTest() + { + var centerPoint = new Point(960, 540); + layouter = CreateLayouter_With_SeveralRectangles(4000, centerPoint); + var rectanglesSquare = 0; + var radius = 0; + foreach (var rectangle in layouter.Rectangles) + { + rectanglesSquare += rectangle.Width * rectangle.Height; + + var x = Math.Max( + Math.Abs(centerPoint.X - rectangle.X), + rectangle.X + rectangle.Width - centerPoint.X + ); + var y = Math.Max( + Math.Abs(centerPoint.Y - rectangle.Y), + rectangle.Y + rectangle.Height - centerPoint.Y + ); + radius = Math.Max(radius, (int)Math.Sqrt(x * x + y * y)); + } + + var circleSquare = Math.PI * radius * radius; + (rectanglesSquare / circleSquare).Should().BeGreaterOrEqualTo(0.75); + } + + private static CircularCloudLayouter CreateLayouter_With_SeveralRectangles(int amount, Point center) + { + var newLayouter = new CircularCloudLayouter(center); + + for (var i = amount; i > 0; i--) + { + var width = i % 40 + 10; + newLayouter.PutNextRectangle(new Size(width, width / 5)); + } + + return newLayouter; + } + } +} diff --git a/cs/TagsCloudVisualizationTests/TagsCloudVisualizationTests.csproj b/cs/TagsCloudVisualizationTests/TagsCloudVisualizationTests.csproj new file mode 100644 index 000000000..624f4c4a1 --- /dev/null +++ b/cs/TagsCloudVisualizationTests/TagsCloudVisualizationTests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + diff --git a/cs/TagsCloudVisualizationTests/TestDataArchimedeanSpiral.cs b/cs/TagsCloudVisualizationTests/TestDataArchimedeanSpiral.cs new file mode 100644 index 000000000..9f1ca45a0 --- /dev/null +++ b/cs/TagsCloudVisualizationTests/TestDataArchimedeanSpiral.cs @@ -0,0 +1,26 @@ +using System.Drawing; +using NUnit.Framework; + +namespace TagsCloudVisualizationTests +{ + public class TestDataArchimedeanSpiral + { + public static IEnumerable Different_CenterPoints() + { + yield return new TestCaseData(new Point(0, 0)).SetName("(0, 0) center"); + yield return new TestCaseData(new Point(343, 868)).SetName("(343, 868) center"); + yield return new TestCaseData(new Point(960, 540)).SetName("(960, 540) center"); + } + + public static IEnumerable DifferentIterationsAdded_ExpectedPoints() + { + yield return new TestCaseData(0, new Point(0, 0)).SetName("0 iterations, central point"); + yield return new TestCaseData(90, new Point(0, (int)(Math.PI / 2))).SetName("90 iterations, half PI"); + yield return new TestCaseData(180, new Point((int)(-Math.PI), 0)).SetName("180 iterations, PI"); + yield return new TestCaseData(270, new Point(0, (int)(-Math.PI * 3 / 2))).SetName("270 iterations, 3/2 PI"); + yield return new TestCaseData(360, new Point((int)(2 * Math.PI), 0)).SetName("360 iterations, double PI"); + yield return new TestCaseData(450, new Point(0, (int)(Math.PI * 5 / 2))).SetName("450 iterations, 5/2 PI"); + yield return new TestCaseData(540, new Point((int)(-3 * Math.PI), 0)).SetName("540 iterations, triple PI"); + } + } +} diff --git a/cs/TagsCloudVisualizationTests/TestDataCircularCloudLayouter.cs b/cs/TagsCloudVisualizationTests/TestDataCircularCloudLayouter.cs new file mode 100644 index 000000000..586060e43 --- /dev/null +++ b/cs/TagsCloudVisualizationTests/TestDataCircularCloudLayouter.cs @@ -0,0 +1,19 @@ +using System.Drawing; +using NUnit.Framework; + +namespace TagsCloudVisualizationTests +{ + public class TestDataCircularCloudLayouter + { + public static IEnumerable ZeroOrLessHeightOrWidth_Size() + { + yield return new TestCaseData(new Size(0, 1)).SetName("Zero_Width_Size"); + yield return new TestCaseData(new Size(1, 0)).SetName("Zero_Height_Size"); + yield return new TestCaseData(new Size(0, 0)).SetName("Zero_Width_And_Height_Size"); + yield return new TestCaseData(new Size(int.MinValue, 1)).SetName("Negative_Width_Size"); + yield return new TestCaseData(new Size(1, int.MinValue)).SetName("Negative_Height_Size"); + yield return new TestCaseData(new Size(int.MinValue, int.MinValue)) + .SetName("Negative_Width_And_Height_Size"); + } + } +} diff --git a/cs/tdd.sln b/cs/tdd.sln index c8f523d63..dde884531 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", "{5C6414B4-E16F-4DE3-974A-5CE1DBA055D2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagsCloudVisualizationTests", "TagsCloudVisualizationTests\TagsCloudVisualizationTests.csproj", "{C97CB590-FA78-4774-B594-28F26FAC15F0}" +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 + {5C6414B4-E16F-4DE3-974A-5CE1DBA055D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C6414B4-E16F-4DE3-974A-5CE1DBA055D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C6414B4-E16F-4DE3-974A-5CE1DBA055D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C6414B4-E16F-4DE3-974A-5CE1DBA055D2}.Release|Any CPU.Build.0 = Release|Any CPU + {C97CB590-FA78-4774-B594-28F26FAC15F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C97CB590-FA78-4774-B594-28F26FAC15F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C97CB590-FA78-4774-B594-28F26FAC15F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C97CB590-FA78-4774-B594-28F26FAC15F0}.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..6179ffdca 100644 --- a/cs/tdd.sln.DotSettings +++ b/cs/tdd.sln.DotSettings @@ -80,4 +80,5 @@ public void SetUp() { $END$ } - \ No newline at end of file + + \ No newline at end of file