diff --git a/cs/TagsCloudVisualization/CircularCloudLayouter.cs b/cs/TagsCloudVisualization/CircularCloudLayouter.cs new file mode 100644 index 000000000..be1d39e12 --- /dev/null +++ b/cs/TagsCloudVisualization/CircularCloudLayouter.cs @@ -0,0 +1,150 @@ +using System.Drawing; + +namespace TagsCloudVisualization; + +public class CircularCloudLayouter : ICircularCloudLayouter +{ + private readonly IList rectangles; + private readonly Point center; + private int layer; + private double angle; + private const double fullCircleTurn = Math.PI * 2; + private const int distanceLayersDifference = 1; + private const double betweenAngleDifference = Math.PI / 36; // 15° + + public CircularCloudLayouter(Point center) + { + this.center = center; + rectangles = new List(); + layer = 0; + angle = 0; + } + + public Rectangle PutNextRectangle(Size rectangleSize) + { + if (rectangleSize.Width == 0 || rectangleSize.Height == 0) + { + throw new ArgumentException("Размер ширины м высоты должен быть больше 0."); + } + + var rectangle = CreateNewRectangle(rectangleSize); + rectangle = RectangleCompressions(rectangle); + rectangles.Add(rectangle); + return rectangle; + } + + public IReadOnlyCollection GetRectangles() + { + return rectangles.AsReadOnly(); + } + + private Rectangle CreateNewRectangle(Size rectangleSize) + { + var rectangleLocation = GetRectangleLocation(rectangleSize); + var rectangle = new Rectangle(rectangleLocation, rectangleSize); + while (CheckRectangleOverlaps(rectangle)) + { + UpdateAngle(); + rectangleLocation = GetRectangleLocation(rectangleSize); + rectangle = new Rectangle(rectangleLocation, rectangleSize); + } + + return rectangle; + } + + private Rectangle RectangleCompressions(Rectangle rectangle) + { + var compressionRectangle = rectangle; + compressionRectangle = Compression(compressionRectangle, + (moveRectangle) => moveRectangle.X > center.X, + (moveRectangle) => moveRectangle.X + rectangle.Width < center.X, + (moveRectangle, direction) => moveRectangle with {X = moveRectangle.X + direction}); + + compressionRectangle = Compression(compressionRectangle, + (moveRectangle) => moveRectangle.Y > center.Y, + (moveRectangle) => moveRectangle.Y + moveRectangle.Height < center.Y, + (moveRectangle, direction) => moveRectangle with {Y = moveRectangle.Y + direction}); + + return compressionRectangle; + } + + private Rectangle Compression(Rectangle rectangle, + Func checkPositiveMove, + Func checkNegativeMove, + Func doMove) + { + if (checkPositiveMove(rectangle) && checkNegativeMove(rectangle) + || !checkPositiveMove(rectangle) && !checkNegativeMove(rectangle)) + { + return rectangle; + } + + var direction = checkPositiveMove(rectangle) ? -1 : 1; + var moveRectangle = rectangle; + while (true) + { + moveRectangle = doMove(moveRectangle, direction); + if (CheckRectangleOverlaps(moveRectangle)) + { + moveRectangle = doMove(moveRectangle, -1 * direction); + break; + } + + if ((direction == -1 && !checkPositiveMove(moveRectangle)) + || (direction == 1 && !checkNegativeMove(moveRectangle))) + { + break; + } + } + + return moveRectangle; + } + + private bool CheckRectangleOverlaps(Rectangle rectangle) + { + return rectangles.Any(r => r.IntersectsWith(rectangle)); + } + + private Point GetRectangleLocation(Size rectangleSize) + { + var shiftFromCenter = -1 * rectangleSize / 2; + if (!rectangles.Any()) + { + UpdateLayer(); + return center + shiftFromCenter; + } + + return CalculateLocationInSpiral() + shiftFromCenter; + } + + private Point CalculateLocationInSpiral() + { + if (!rectangles.Any()) + { + throw new ArgumentException( + "Должен быть хотя бы один прямоугольник для вычисления позиции для следующего вне центра спирали."); + } + + var x = center.X + layer * distanceLayersDifference * Math.Cos(angle); + var y = center.Y + layer * distanceLayersDifference * Math.Sin(angle); + var wholeX = Convert.ToInt32(x); + var wholeY = Convert.ToInt32(y); + var newLocation = new Point(wholeX, wholeY); + return newLocation; + } + + private void UpdateAngle() + { + angle += betweenAngleDifference; + if (angle > fullCircleTurn) + { + UpdateLayer(); + angle = 0; + } + } + + private void UpdateLayer() + { + layer++; + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/Extensions/RectangleExtensions.cs b/cs/TagsCloudVisualization/Extensions/RectangleExtensions.cs new file mode 100644 index 000000000..7eac9a48c --- /dev/null +++ b/cs/TagsCloudVisualization/Extensions/RectangleExtensions.cs @@ -0,0 +1,12 @@ +using System.Drawing; + +namespace TagsCloudVisualization.Extensions; + +public static class RectangleExtensions +{ + public static Point Center(this Rectangle rectangle) + { + return new Point(rectangle.Left + rectangle.Width / 2, + rectangle.Top + rectangle.Height / 2); + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/ICircularCloudLayouter.cs b/cs/TagsCloudVisualization/ICircularCloudLayouter.cs new file mode 100644 index 000000000..0804b3f1d --- /dev/null +++ b/cs/TagsCloudVisualization/ICircularCloudLayouter.cs @@ -0,0 +1,9 @@ +using System.Drawing; + +namespace TagsCloudVisualization; + +public interface ICircularCloudLayouter +{ + public Rectangle PutNextRectangle(Size rectangleSize); + public IReadOnlyCollection GetRectangles(); +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/ITagsCloudVisualizer.cs b/cs/TagsCloudVisualization/ITagsCloudVisualizer.cs new file mode 100644 index 000000000..f96e0fcb2 --- /dev/null +++ b/cs/TagsCloudVisualization/ITagsCloudVisualizer.cs @@ -0,0 +1,10 @@ +using System.Drawing; +using System.Drawing.Imaging; + +namespace TagsCloudVisualization; + +public interface ITagsCloudVisualizer : IDisposable +{ + public void AddVisualizationRectangles(IEnumerable rectangles); + public void Save(string outputFilePath, ImageFormat imageFormat); +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/Images/100RectanglesFrom50To100Sizes.png b/cs/TagsCloudVisualization/Images/100RectanglesFrom50To100Sizes.png new file mode 100644 index 000000000..f3965a7c3 Binary files /dev/null and b/cs/TagsCloudVisualization/Images/100RectanglesFrom50To100Sizes.png differ diff --git a/cs/TagsCloudVisualization/Images/100RectanglesFrom50To50Sizes.png b/cs/TagsCloudVisualization/Images/100RectanglesFrom50To50Sizes.png new file mode 100644 index 000000000..e95ef9904 Binary files /dev/null and b/cs/TagsCloudVisualization/Images/100RectanglesFrom50To50Sizes.png differ diff --git a/cs/TagsCloudVisualization/Images/100RectanglesFrom75To100Sizes.png b/cs/TagsCloudVisualization/Images/100RectanglesFrom75To100Sizes.png new file mode 100644 index 000000000..e49c48b3c Binary files /dev/null and b/cs/TagsCloudVisualization/Images/100RectanglesFrom75To100Sizes.png differ diff --git a/cs/TagsCloudVisualization/Images/100RectanglesWithWidthFrom50To75AndHeightFrom25To50Sizes.png b/cs/TagsCloudVisualization/Images/100RectanglesWithWidthFrom50To75AndHeightFrom25To50Sizes.png new file mode 100644 index 000000000..96995c0e1 Binary files /dev/null and b/cs/TagsCloudVisualization/Images/100RectanglesWithWidthFrom50To75AndHeightFrom25To50Sizes.png differ diff --git a/cs/TagsCloudVisualization/README.md b/cs/TagsCloudVisualization/README.md new file mode 100644 index 000000000..09bc6ed51 --- /dev/null +++ b/cs/TagsCloudVisualization/README.md @@ -0,0 +1,5 @@ +### Сгенерированные изображения с разными параметрами для примера +1) [100RectanglesFrom50To50Sizes.png](https://github.com/ILFirV-V/tdd/blob/master/cs/TagsCloudVisualization/Images/100RectanglesFrom50To50Sizes.png) +2) [100RectanglesFrom50To100Sizes.png](https://github.com/ILFirV-V/tdd/blob/master/cs/TagsCloudVisualization/Images/100RectanglesFrom50To100Sizes.png) +3) [100RectanglesFrom75To100Sizes.png](https://github.com/ILFirV-V/tdd/blob/master/cs/TagsCloudVisualization/Images/100RectanglesFrom75To100Sizes.png) +4) [100RectanglesWithWidthFrom50To75AndHeightFrom25To50Sizes.png](https://github.com/ILFirV-V/tdd/blob/master/cs/TagsCloudVisualization/Images/100RectanglesWithWidthFrom50To75AndHeightFrom25To50Sizes.png) diff --git a/cs/TagsCloudVisualization/TagsCloudVisualization.csproj b/cs/TagsCloudVisualization/TagsCloudVisualization.csproj new file mode 100644 index 000000000..b0c18b858 --- /dev/null +++ b/cs/TagsCloudVisualization/TagsCloudVisualization.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/cs/TagsCloudVisualization/TagsCloudVisualizer.cs b/cs/TagsCloudVisualization/TagsCloudVisualizer.cs new file mode 100644 index 000000000..2c047e029 --- /dev/null +++ b/cs/TagsCloudVisualization/TagsCloudVisualizer.cs @@ -0,0 +1,86 @@ +using System.Drawing; +using System.Drawing.Imaging; +using TagsCloudVisualization.Extensions; + +namespace TagsCloudVisualization; + +public class TagsCloudVisualizer : ITagsCloudVisualizer +{ + private bool isDisposed; + private readonly Bitmap bitmap; + + public TagsCloudVisualizer(Size imageSize) + { + bitmap = new Bitmap(imageSize.Width, imageSize.Height); + isDisposed = false; + } + + public void AddVisualizationRectangles(IEnumerable rectangles) + { + using var graphics = Graphics.FromImage(bitmap); + VisualizeRectangles(graphics, rectangles); + VisualizeCenter(graphics, rectangles.First()); + } + + public void Save(string outputFilePath, ImageFormat imageFormat) + { + bitmap.Save(outputFilePath, imageFormat); + } + + private void VisualizeRectangles(Graphics graphics, IEnumerable rectangles) + { + foreach (var rectangle in rectangles) + { + VisualizeRectangle(graphics, rectangle); + } + } + + private void VisualizeRectangle(Graphics graphics, Rectangle rectangle) + { + graphics.FillRectangle(Brushes.Blue, rectangle); + graphics.DrawRectangle(Pens.Black, rectangle); + } + + private void VisualizeCenter(Graphics graphics, Rectangle firstRectangle) + { + var center = firstRectangle.Center(); + var centerRectangle = CalculateCentralRectangle(center); + VisualizeCenterPoint(graphics, centerRectangle); + } + + private Rectangle CalculateCentralRectangle(Point center) + { + var size = new Size(2, 2); + return new Rectangle(center.X - size.Width / 2, center.Y - size.Height / 2, size.Width, size.Height); + } + + private void VisualizeCenterPoint(Graphics graphics, Rectangle centerRectangle) + { + graphics.FillEllipse(Brushes.Red, centerRectangle); + } + + ~TagsCloudVisualizer() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + } + + protected virtual void Dispose(bool fromDisposeMethod) + { + if (isDisposed) + { + return; + } + + if (fromDisposeMethod) + { + bitmap.Dispose(); + } + + isDisposed = true; + } +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/Tests/CircularCloudLayouterTestCases.cs b/cs/TagsCloudVisualization/Tests/CircularCloudLayouterTestCases.cs new file mode 100644 index 000000000..9a659f989 --- /dev/null +++ b/cs/TagsCloudVisualization/Tests/CircularCloudLayouterTestCases.cs @@ -0,0 +1,38 @@ +using System.Drawing; +using NUnit.Framework; + +namespace TagsCloudVisualization.Tests; + +internal static class CircularCloudLayouterTestCases +{ + internal static readonly IEnumerable GetRectanglesWithZeroSizesTestData = + [ + new TestCaseData(new Point(0, 0), new Size(0, 0)) + .SetName("AllZero"), + new TestCaseData(new Point(0, 0), new Size(1, 0)) + .SetName("WidthZero"), + new TestCaseData(new Point(0, 0), new Size(0, 1)) + .SetName("HeightZero"), + ]; + + internal static readonly IEnumerable GetCorrectRectangleSizesWithEndsLocationTestData = + [ + new TestCaseData(new Point(10, 10), new List() {new(10, 10)}, new Point(10, 10) - new Size(10, 10) / 2) + .SetName("FirstRectangleInCenter"), + ]; + + internal static readonly IEnumerable GetCorrectUnusualRectanglesSizeTestData = + [ + new TestCaseData(new Point(100, 100), Array.Empty()) + .SetName("ArraySizeEmpty"), + ]; + + internal static readonly IEnumerable GetCorrectRectangleSizesTestData = + [ + new TestCaseData(new Point(100, 100), new List() {new(10, 10)}) + .SetName("OneSize"), + new TestCaseData(new Point(100, 100), new List() + {new(10, 10), new(20, 20), new(15, 15), new(5, 7), new(3, 1), new(15, 35)}) + .SetName("MoreSizes"), + ]; +} \ No newline at end of file diff --git a/cs/TagsCloudVisualization/Tests/CircularCloudLayouterTests.cs b/cs/TagsCloudVisualization/Tests/CircularCloudLayouterTests.cs new file mode 100644 index 000000000..74b131ca0 --- /dev/null +++ b/cs/TagsCloudVisualization/Tests/CircularCloudLayouterTests.cs @@ -0,0 +1,158 @@ +using System.Drawing; +using System.Drawing.Imaging; +using FluentAssertions; +using NUnit.Framework; +using NUnit.Framework.Interfaces; +using TagsCloudVisualization.Extensions; + +namespace TagsCloudVisualization.Tests; + +[TestFixture] +public class CircularCloudLayouterTests +{ + private ICircularCloudLayouter circularCloudLayouter; + + private static IEnumerable withZeroSizeTestCases + = CircularCloudLayouterTestCases.GetRectanglesWithZeroSizesTestData; + + private static IEnumerable checkRectangleLocationTestCases + = CircularCloudLayouterTestCases.GetCorrectRectangleSizesWithEndsLocationTestData; + + private static IEnumerable checkRectangleSizeTestCases + = CircularCloudLayouterTestCases.GetCorrectRectangleSizesTestData; + + private static IEnumerable checkUnusualRectangleSizeTestCases + = CircularCloudLayouterTestCases.GetCorrectUnusualRectanglesSizeTestData; + + [TearDown] + public void TearDown() + { + if (TestContext.CurrentContext.Result.Outcome.Status != TestStatus.Failed) + return; + var rectangles = circularCloudLayouter.GetRectangles(); + var imageSize = new Size(1000, 1000); + using var tagsCloudVisualizer = new TagsCloudVisualizer(imageSize); + tagsCloudVisualizer.AddVisualizationRectangles(rectangles); + SaveErrorVisualization(tagsCloudVisualizer, TestContext.CurrentContext); + } + + [Test] + [TestCaseSource(nameof(withZeroSizeTestCases))] + public void PutNextRectangle_ShouldThrowArgumentException_WhenSizeIsZero(Point center, Size rectangleSize) + { + circularCloudLayouter = new CircularCloudLayouter(center); + + var action = () => circularCloudLayouter.PutNextRectangle(rectangleSize); + + action.Should() + .Throw() + .WithMessage("Размер ширины м высоты должен быть больше 0."); + } + + [Test] + [TestCaseSource(nameof(checkRectangleLocationTestCases))] + public void PutNextRectangle_ShouldLastRectanglesInLocation_WhenBeforePutNextRectangles(Point center, + IList sizes, Point shouldLocation) + { + if (!sizes.Any()) + { + throw new ArgumentNullException(nameof(sizes)); + } + + circularCloudLayouter = new CircularCloudLayouter(center); + + var rectangles = sizes.Select(size => circularCloudLayouter.PutNextRectangle(size)).ToList(); + var rectangle = rectangles.Last(); + + rectangle.Location.Should() + .BeEquivalentTo(shouldLocation); + } + + [Test] + [TestCaseSource(nameof(checkRectangleSizeTestCases))] + public void PutNextRectangle_ShouldFirstRectanglesInCenter_WhenAfterPutNextRectangles(Point center, + IList sizes) + { + if (!sizes.Any()) + { + throw new ArgumentNullException(nameof(sizes)); + } + + circularCloudLayouter = new CircularCloudLayouter(center); + var firstRectangleSize = sizes.First(); + + var firstRectangle = circularCloudLayouter.PutNextRectangle(firstRectangleSize); + var _ = sizes.Skip(1).Select(size => circularCloudLayouter.PutNextRectangle(size)); + + firstRectangle.Center().Should() + .BeEquivalentTo(center); + } + + [Test] + [TestCaseSource(nameof(checkRectangleSizeTestCases))] + [TestCaseSource(nameof(checkUnusualRectangleSizeTestCases))] + public void PutNextRectangle_ShouldRectanglesNotInCenter_WhenPutNextRectangles(Point center, IList sizes) + { + var rectangles = new List(); + circularCloudLayouter = new CircularCloudLayouter(center); + + if (sizes.Any()) + { + var _ = circularCloudLayouter.PutNextRectangle(sizes.First()); + rectangles.AddRange(sizes.Skip(1).Select(size => circularCloudLayouter.PutNextRectangle(size))); + } + + rectangles.Should().NotContain(r => + r.Contains(center) + ); + } + + [Test] + [TestCaseSource(nameof(checkRectangleSizeTestCases))] + [TestCaseSource(nameof(checkUnusualRectangleSizeTestCases))] + public void PutNextRectangle_ShouldEquivalentSize_WhenPutNextRectangles(Point center, IList sizes) + { + circularCloudLayouter = new CircularCloudLayouter(center); + var rectangles = sizes.Select(size => circularCloudLayouter.PutNextRectangle(size)).ToList(); + + rectangles.Select(r => r.Size).Should() + .BeEquivalentTo(sizes); + } + + [Test] + [TestCaseSource(nameof(checkRectangleSizeTestCases))] + [TestCaseSource(nameof(checkUnusualRectangleSizeTestCases))] + public void PutNextRectangle_NotShouldIntersect_WhenPutNextRectangles(Point center, IList sizes) + { + circularCloudLayouter = new CircularCloudLayouter(center); + var rectangles = sizes.Select(size => circularCloudLayouter.PutNextRectangle(size)).ToList(); + var intersectingRectangles = new List(); + + foreach (var checkRectangle in rectangles) + { + intersectingRectangles.AddRange(rectangles + .Where(rectangle => checkRectangle != rectangle && rectangle.IntersectsWith(checkRectangle))); + } + + intersectingRectangles.Should().BeEmpty(); + } + + private void SaveErrorVisualization(ITagsCloudVisualizer tagsCloudVisualizer, TestContext testContext) + { + var imageFormat = ImageFormat.Png; + var imagePath = GetTestErrorImagesPath(testContext, imageFormat); + tagsCloudVisualizer.Save(imagePath, imageFormat); + TestContext.WriteLine($"Tag cloud visualization saved to file {imagePath}"); + } + + private string GetTestErrorImagesPath(TestContext testContext, ImageFormat imageFormat) + { + const string imagesFolderName = "ImagesWhenErrorInTests"; + var path = testContext.TestDirectory; + var imagesDirectoryPath = Path.Combine(path, imagesFolderName); + var testName = testContext.Test.Name; + Directory.CreateDirectory(imagesDirectoryPath); + var fileName = Path.ChangeExtension(testName, imageFormat.ToString().ToLowerInvariant()); + return Path.Combine(imagesDirectoryPath, fileName); + } +} \ No newline at end of file diff --git a/cs/tdd.sln b/cs/tdd.sln index c8f523d63..24965180c 100644 --- a/cs/tdd.sln +++ b/cs/tdd.sln @@ -7,6 +7,8 @@ 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", "{4115D7AB-9182-4A6D-AC0F-9C209291B549}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ 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 + {4115D7AB-9182-4A6D-AC0F-9C209291B549}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4115D7AB-9182-4A6D-AC0F-9C209291B549}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4115D7AB-9182-4A6D-AC0F-9C209291B549}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4115D7AB-9182-4A6D-AC0F-9C209291B549}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE