diff --git a/.gitignore b/.gitignore index f4b7c770..741c4b3f 100644 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,4 @@ project/metals.sbt ## NPM node_modules/ +targets.tar diff --git a/algebra/shared/src/main/scala/doodle/algebra/Layout.scala b/algebra/shared/src/main/scala/doodle/algebra/Layout.scala index ee2be138..825aa12b 100644 --- a/algebra/shared/src/main/scala/doodle/algebra/Layout.scala +++ b/algebra/shared/src/main/scala/doodle/algebra/Layout.scala @@ -55,6 +55,32 @@ trait Layout extends Algebra { left: Double ): Drawing[A] + /** Expand the bounding box of img by the given amounts. Each Landmark + * parameter can specify either absolute (point) or relative (percent) + * dimensions. Percentages are relative to the current bounding box size: + * left/right margins are percentages of the current width, top/bottom + * margins are percentages of the current height. + * + * The default implementation evaluates Landmarks against a zero-sized + * bounding box (so percentage values resolve to 0 and point values give + * their absolute amounts). Override for full bbox-aware behaviour (as + * GenericLayout does). + */ + def margin[A]( + img: Drawing[A], + top: Landmark, + right: Landmark, + bottom: Landmark, + left: Landmark + ): Drawing[A] = + margin( + img, + top.y.eval(0, 0), + right.x.eval(0, 0), + bottom.y.eval(0, 0), + left.x.eval(0, 0) + ) + /** Set the width and height of the given `Drawing's` bounding box to the * given values. The new bounding box has the same origin as the original * bounding box, and extends symmetrically above and below, and left and @@ -62,6 +88,20 @@ trait Layout extends Algebra { */ def size[A](img: Drawing[A], width: Double, height: Double): Drawing[A] + /** Set the width and height of the given `Drawing's` bounding box. Each + * Landmark parameter can specify either absolute (point) or relative + * (percent) dimensions. Percentages are relative to the current bounding box + * size. For example, Landmark.percent(200, 200) will double the size, while + * Landmark.percent(50, 50) will halve it. + * + * The default implementation evaluates Landmarks against a zero-sized + * bounding box (so percentage values resolve to 0 and point values give + * their absolute amounts). Override for full bbox-aware behaviour (as + * GenericLayout does). + */ + def size[A](img: Drawing[A], width: Landmark, height: Landmark): Drawing[A] = + size(img, width.x.eval(0, 0), height.y.eval(0, 0)) + // Derived methods def under[A](bottom: Drawing[A], top: Drawing[A])(implicit @@ -100,7 +140,30 @@ trait Layout extends Algebra { def margin[A](img: Drawing[A], width: Double): Drawing[A] = margin(img, width, width, width, width) + /** Expand the bounding box by horizontal and vertical margins specified as + * Landmarks. Supports both absolute and percentage-based margins. + */ + def margin[A]( + img: Drawing[A], + horizontal: Landmark, + vertical: Landmark + ): Drawing[A] = + margin(img, vertical, horizontal, vertical, horizontal) + + /** Expand the bounding box by the same margin on all sides, specified as a + * Landmark. Supports both absolute and percentage-based margins. + */ + def margin[A](img: Drawing[A], all: Landmark): Drawing[A] = + margin(img, all, all, all, all) + /** Utility to set the width and height to the same value. */ def size[A](img: Drawing[A], extent: Double): Drawing[A] = size(img, extent, extent) + + /** Set the width and height to the same value, specified as a Landmark. + * Supports both absolute and percentage-based sizing. For example, size(img, + * Landmark.percent(200, 200)) will double the size. + */ + def size[A](img: Drawing[A], extent: Landmark): Drawing[A] = + size(img, extent, extent) } diff --git a/algebra/shared/src/main/scala/doodle/algebra/generic/GenericLayout.scala b/algebra/shared/src/main/scala/doodle/algebra/generic/GenericLayout.scala index d2a11f91..4a5f6e74 100644 --- a/algebra/shared/src/main/scala/doodle/algebra/generic/GenericLayout.scala +++ b/algebra/shared/src/main/scala/doodle/algebra/generic/GenericLayout.scala @@ -145,4 +145,72 @@ trait GenericLayout[G[_]] extends Layout { (newBb, rdr) } } + override def margin[A]( + img: Finalized[G, A], + top: Landmark, + right: Landmark, + bottom: Landmark, + left: Landmark + ): Finalized[G, A] = + img.map { case (bb, rdr) => + // Evaluate landmarks relative to current bounding box dimensions + // For left/right: use width as the reference dimension + // For top/bottom: use height as the reference dimension + val width = bb.width + val height = bb.height + + // Evaluate each landmark coordinate + // Using the x-coordinate of the landmark for horizontal margins + // Using the y-coordinate of the landmark for vertical margins + val topMargin = top.y.eval(0, height) + val rightMargin = right.x.eval(0, width) + val bottomMargin = bottom.y.eval(0, height) + val leftMargin = left.x.eval(0, width) + + val newBb = BoundingBox( + left = bb.left - leftMargin, + top = bb.top + topMargin, + right = bb.right + rightMargin, + bottom = bb.bottom - bottomMargin + ) + (newBb, rdr) + } + + override def size[A]( + img: Finalized[G, A], + width: Landmark, + height: Landmark + ): Finalized[G, A] = + img.map { case (bb, rdr) => + // Evaluate landmarks relative to current bounding box dimensions + // Using the x-coordinate for width and y-coordinate for height + val currentWidth = bb.width + val currentHeight = bb.height + + // Evaluate the new dimensions + val newWidth = width.x.eval(0, currentWidth) + val newHeight = height.y.eval(0, currentHeight) + + // Validate the new dimensions + assert( + newWidth >= 0, + s"Evaluated size resulted in a width of ${newWidth}. The bounding box's width must be non-negative." + ) + assert( + newHeight >= 0, + s"Evaluated size resulted in a height of ${newHeight}. The bounding box's height must be non-negative." + ) + + val w = newWidth / 2.0 + val h = newHeight / 2.0 + + val newBb = BoundingBox( + left = -w, + top = h, + right = w, + bottom = -h + ) + + (newBb, rdr) + } } diff --git a/algebra/shared/src/main/scala/doodle/syntax/LayoutSyntax.scala b/algebra/shared/src/main/scala/doodle/syntax/LayoutSyntax.scala index 642e84d1..0dcbd40e 100644 --- a/algebra/shared/src/main/scala/doodle/syntax/LayoutSyntax.scala +++ b/algebra/shared/src/main/scala/doodle/syntax/LayoutSyntax.scala @@ -174,5 +174,44 @@ trait LayoutSyntax { def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] = algebra.size(picture(algebra), extent) } + // Landmark-based margin methods + def margin( + top: Landmark, + right: Landmark, + bottom: Landmark, + left: Landmark + ): Picture[Alg with Layout, A] = + new Picture[Alg with Layout, A] { + def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] = + algebra.margin(picture(algebra), top, right, bottom, left) + } + + def margin( + horizontal: Landmark, + vertical: Landmark + ): Picture[Alg with Layout, A] = + new Picture[Alg with Layout, A] { + def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] = + algebra.margin(picture(algebra), horizontal, vertical) + } + + def margin(all: Landmark): Picture[Alg with Layout, A] = + new Picture[Alg with Layout, A] { + def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] = + algebra.margin(picture(algebra), all) + } + + // Landmark-based size methods + def size(width: Landmark, height: Landmark): Picture[Alg with Layout, A] = + new Picture[Alg with Layout, A] { + def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] = + algebra.size(picture(algebra), width, height) + } + + def size(extent: Landmark): Picture[Alg with Layout, A] = + new Picture[Alg with Layout, A] { + def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] = + algebra.size(picture(algebra), extent) + } } } diff --git a/examples/jvm/src/main/scala/doodle/examples/LandmarkLayoutExamples.scala b/examples/jvm/src/main/scala/doodle/examples/LandmarkLayoutExamples.scala new file mode 100644 index 00000000..92782b23 --- /dev/null +++ b/examples/jvm/src/main/scala/doodle/examples/LandmarkLayoutExamples.scala @@ -0,0 +1,120 @@ +/* + * Copyright 2015 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package doodle.examples + +import cats.effect.unsafe.implicits.global +import doodle.core.* +import doodle.java2d.* +import doodle.syntax.all.* + +object LandmarkLayoutExamples { + + /** Example 1: Percentage-based sizing + * + * Demonstrates using Landmarks to create scaled versions of shapes + */ + def percentageSizing = { + val baseCircle = circle(50).fillColor(Color.royalBlue) + + // Double the size (200%) + val doubleSize = baseCircle.size(Landmark.percent(200, 200)) + + // Half the size (50%) + val halfSize = baseCircle.size(Landmark.percent(50, 50)) + + // Different scaling in each dimension + val stretched = baseCircle.size(Landmark.percent(100, 150)) + + halfSize.beside(baseCircle).beside(doubleSize).beside(stretched) + } + + /** Example 2: Percentage-based margins + * + * Shows how to use relative margins for responsive spacing + */ + def percentageMargins = { + val box = square(100).fillColor(Color.crimson) + + // Add margin of 50% of current width/height on all sides + val withMargin = box.margin(Landmark.percent(50, 50)) + + // Add different margins on each side (top/bottom: 25%, left/right: 75%) + val asymmetricMargin = box.margin( + Landmark.percent(0, 75), // horizontal (left/right) + Landmark.percent(25, 0) // vertical (top/bottom) + ) + + withMargin.beside(asymmetricMargin) + } + + /** Example 3: Mixing absolute and percentage values + * + * Demonstrates combining Point (absolute) and Percent coordinates + */ + def mixedValues = { + val rect = rectangle(80, 40).fillColor(Color.seaGreen) + + // Size: 100% width, 200 pixels height + val mixedSize = rect.size( + Landmark(Coordinate.percent(100), Coordinate.point(200)), + Landmark(Coordinate.percent(100), Coordinate.point(200)) + ) + + rect.beside(mixedSize) + } + + /** Example 4: Responsive grid layout + * + * Creates a grid where elements scale proportionally + */ + def responsiveGrid = { + val cell = square(60).fillColor(Color.hotPink) + + // Each cell scales to 80% of its size + val scaled = cell.size(Landmark.percent(80, 80)) + + // Add uniform margin of 20% around each cell + val withSpacing = scaled.margin(Landmark.percent(20, 20)) + + val row = withSpacing.beside(withSpacing).beside(withSpacing) + row.above(row).above(row) + } + + /** Example 5: Progressive scaling + * + * Chain percentage operations to create compound effects + */ + def progressiveScaling = { + val start = circle(40).fillColor(Color.orange) + + // Each subsequent circle is 125% of the previous one + val s1 = start.size(Landmark.percent(125, 125)) + val s2 = s1.size(Landmark.percent(125, 125)) + val s3 = s2.size(Landmark.percent(125, 125)) + + start.beside(s1).beside(s2).beside(s3) + } + + def main(args: Array[String]): Unit = { + // Render all examples + percentageSizing.draw() + percentageMargins.draw() + mixedValues.draw() + responsiveGrid.draw() + progressiveScaling.draw() + } +}