diff --git a/build.sbt b/build.sbt index 579889da..4f521a7d 100644 --- a/build.sbt +++ b/build.sbt @@ -300,6 +300,6 @@ lazy val examples = crossProject(JSPlatform, JVMPlatform) ) .jsConfigure( _.settings(mimaPreviousArtifacts := Set.empty) - .dependsOn(core.js, image.js, interact.js) + .dependsOn(core.js, canvas, image.js, interact.js) ) .dependsOn(svg) diff --git a/canvas/src/main/scala/doodle/canvas/Algebra.scala b/canvas/src/main/scala/doodle/canvas/Algebra.scala new file mode 100644 index 00000000..b0d97308 --- /dev/null +++ b/canvas/src/main/scala/doodle/canvas/Algebra.scala @@ -0,0 +1,19 @@ +/* + * 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.canvas + +type Algebra = doodle.canvas.algebra.Algebra diff --git a/canvas/src/main/scala/doodle/canvas/Drawing.scala b/canvas/src/main/scala/doodle/canvas/Drawing.scala new file mode 100644 index 00000000..d2e7d1e4 --- /dev/null +++ b/canvas/src/main/scala/doodle/canvas/Drawing.scala @@ -0,0 +1,20 @@ +/* + * 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.canvas + +type Drawing[A] = + doodle.algebra.generic.Finalized[doodle.canvas.algebra.CanvasDrawing, A] diff --git a/canvas/src/main/scala/doodle/canvas/Frame.scala b/canvas/src/main/scala/doodle/canvas/Frame.scala new file mode 100644 index 00000000..8c314e9a --- /dev/null +++ b/canvas/src/main/scala/doodle/canvas/Frame.scala @@ -0,0 +1,20 @@ +/* + * 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.canvas + +type Frame = doodle.canvas.effect.Frame +val Frame = doodle.canvas.effect.Frame diff --git a/canvas/src/main/scala/doodle/canvas/Picture.scala b/canvas/src/main/scala/doodle/canvas/Picture.scala new file mode 100644 index 00000000..c55c1ec4 --- /dev/null +++ b/canvas/src/main/scala/doodle/canvas/Picture.scala @@ -0,0 +1,27 @@ +/* + * 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.canvas + +import doodle.algebra.BaseConstructor +import doodle.algebra.ShapeConstructor + +type Picture[A] = doodle.algebra.Picture[Algebra, A] +object Picture extends BaseConstructor, ShapeConstructor { + + type Algebra = doodle.canvas.Algebra + type Drawing[A] = doodle.canvas.Drawing[A] +} diff --git a/canvas/src/main/scala/doodle/canvas/algebra/Algebra.scala b/canvas/src/main/scala/doodle/canvas/algebra/Algebra.scala new file mode 100644 index 00000000..ffd4e8f5 --- /dev/null +++ b/canvas/src/main/scala/doodle/canvas/algebra/Algebra.scala @@ -0,0 +1,70 @@ +/* + * 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.canvas.algebra + +import cats.Apply +import cats.Eval +import cats.Functor +import cats.Monad +import doodle.algebra.generic._ +import doodle.core.BoundingBox +import org.scalajs.dom.CanvasRenderingContext2D + +final case class Algebra( + ctx: CanvasRenderingContext2D, + applyDrawing: Apply[CanvasDrawing] = Apply.apply[CanvasDrawing], + functorDrawing: Functor[CanvasDrawing] = Apply.apply[CanvasDrawing] +) extends Shape, + GenericDebug[CanvasDrawing], + GenericLayout[CanvasDrawing], + GenericSize[CanvasDrawing], + GenericStyle[CanvasDrawing], + GenericTransform[CanvasDrawing], + GivenApply[CanvasDrawing], + GivenFunctor[CanvasDrawing], + doodle.algebra.Algebra { + type Drawing[A] = doodle.canvas.Drawing[A] + implicit val drawingInstance: Monad[Drawing] = + new Monad[Drawing] { + def pure[A](x: A): Drawing[A] = + Finalized.leaf(_ => + ( + BoundingBox.empty, + Renderable.apply(_ => Eval.now(CanvasDrawing.pure(x))) + ) + ) + + def flatMap[A, B](fa: Drawing[A])(f: A => Drawing[B]): Drawing[B] = + fa.flatMap { (bb, rdr) => + val canvasDrawing = rdr.runA(doodle.core.Transform.identity).value + val a = canvasDrawing(ctx) + f(a) + } + + def tailRecM[A, B](a: A)(f: A => Drawing[Either[A, B]]): Drawing[B] = { + // TODO: This implementation is not tail recursive but I don't think we need it for what we use in Doodle + val dAB = f(a) + flatMap(dAB)(either => + either match { + case Left(a) => tailRecM(a)(f) + case Right(b) => dAB.asInstanceOf[Drawing[B]] + } + ) + } + } + +} diff --git a/canvas/src/main/scala/doodle/canvas/algebra/CanvasDrawing.scala b/canvas/src/main/scala/doodle/canvas/algebra/CanvasDrawing.scala index 37360d28..2d85cad5 100644 --- a/canvas/src/main/scala/doodle/canvas/algebra/CanvasDrawing.scala +++ b/canvas/src/main/scala/doodle/canvas/algebra/CanvasDrawing.scala @@ -16,8 +16,14 @@ package doodle.canvas.algebra -import doodle.core.Color +import cats.Apply +import doodle.algebra.generic.Fill +import doodle.algebra.generic.Fill.ColorFill +import doodle.algebra.generic.Fill.GradientFill import doodle.algebra.generic.Stroke +import doodle.core.Cap +import doodle.core.Color +import doodle.core.Join import org.scalajs.dom.CanvasRenderingContext2D /** A canvas `Drawing` is a function that, when applied, produces a value of @@ -25,16 +31,68 @@ import org.scalajs.dom.CanvasRenderingContext2D */ opaque type CanvasDrawing[A] = Function[CanvasRenderingContext2D, A] object CanvasDrawing { + given Apply[CanvasDrawing] with { + def ap[A, B](ff: CanvasDrawing[A => B])( + fa: CanvasDrawing[A] + ): CanvasDrawing[B] = + CanvasDrawing(ctx => ff(ctx)(fa(ctx))) + + def map[A, B](fa: CanvasDrawing[A])(f: A => B): CanvasDrawing[B] = + CanvasDrawing(ctx => f(fa(ctx))) + } + + def pure[A](value: A): CanvasDrawing[A] = + CanvasDrawing(_ => value) + + /** CanvasDrawing that does nothing */ + val unit: CanvasDrawing[Unit] = + pure(()) + + extension [A](drawing: CanvasDrawing[A]) { + def apply(ctx: CanvasRenderingContext2D): A = + drawing(ctx) + + def >>[B](that: CanvasDrawing[B]): CanvasDrawing[B] = + CanvasDrawing { ctx => + drawing(ctx) + that(ctx) + } + } + def apply[A](f: CanvasRenderingContext2D => A): CanvasDrawing[A] = f def colorToCSS(color: Color): String = s"rgb(${color.red} ${color.green} ${color.blue} / ${color.alpha})" + def setFill(fill: Fill): CanvasDrawing[Unit] = { + CanvasDrawing { ctx => + // TODO: Implement + fill match { + case ColorFill(color) => () + case GradientFill(gradient) => () + } + } + } + + def setStroke(stroke: Option[Stroke]): CanvasDrawing[Unit] = + stroke.map(setStroke).getOrElse(unit) + def setStroke(stroke: Stroke): CanvasDrawing[Unit] = { val Stroke(color, width, cap, join, dash) = stroke - CanvasDrawing{ ctx => + CanvasDrawing { ctx => ctx.strokeStyle = colorToCSS(color) ctx.lineWidth = width + ctx.lineCap = cap match { + case Cap.Butt => "butt" + case Cap.Round => "round" + case Cap.Square => "square" + } + ctx.lineJoin = join match { + case Join.Bevel => "bevel" + case Join.Round => "round" + case Join.Miter => "miter" + } + // TODO: Implement dash } } } diff --git a/canvas/src/main/scala/doodle/canvas/algebra/Shape.scala b/canvas/src/main/scala/doodle/canvas/algebra/Shape.scala index 5f041f1a..3ebd61de 100644 --- a/canvas/src/main/scala/doodle/canvas/algebra/Shape.scala +++ b/canvas/src/main/scala/doodle/canvas/algebra/Shape.scala @@ -17,7 +17,12 @@ package doodle.canvas.algebra import doodle.algebra.Algebra -import doodle.algebra.generic.* +import doodle.algebra.generic._ +import doodle.core.PathElement +import doodle.core.PathElement.BezierCurveTo +import doodle.core.PathElement.LineTo +import doodle.core.PathElement.MoveTo +import doodle.core.Point import doodle.core.{Transform => Tx} trait Shape extends GenericShape[CanvasDrawing] { @@ -31,9 +36,8 @@ trait Shape extends GenericShape[CanvasDrawing] { width: Double, height: Double ): CanvasDrawing[Unit] = - CanvasDrawing { ctx => - ctx.strokeRect(width, height, width, height) - } + CanvasDrawing.setStroke(stroke) >> + CanvasDrawing(ctx => ctx.strokeRect(width, height, width, height)) def triangle( tx: Tx, @@ -50,7 +54,20 @@ trait Shape extends GenericShape[CanvasDrawing] { stroke: Option[Stroke], diameter: Double ): CanvasDrawing[Unit] = - ??? + CanvasDrawing.setStroke(stroke) >> CanvasDrawing { ctx => + val path = PathElement.circle(Point.zero, diameter) + ctx.beginPath() + path.foreach(elt => + elt match { + case MoveTo(to) => ctx.moveTo(to.x, to.y) + case LineTo(to) => ctx.lineTo(to.x, to.y) + case BezierCurveTo(cp1, cp2, to) => + ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, to.x, to.y) + } + ) + ctx.closePath() + ctx.stroke() + } def unit: CanvasDrawing[Unit] = ??? diff --git a/canvas/src/main/scala/doodle/canvas/effect/Canvas.scala b/canvas/src/main/scala/doodle/canvas/effect/Canvas.scala new file mode 100644 index 00000000..00aa8e99 --- /dev/null +++ b/canvas/src/main/scala/doodle/canvas/effect/Canvas.scala @@ -0,0 +1,56 @@ +/* + * 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.canvas.effect + +import cats.effect.IO +import doodle.algebra.generic.Finalized +import doodle.canvas.Picture +import doodle.canvas.algebra.Algebra +import doodle.canvas.algebra.CanvasDrawing +import doodle.core.Transform +import org.scalajs.dom + +final case class Canvas(target: dom.Node, frame: Frame) { + val canvas: dom.html.Canvas = + dom.document.createElement("canvas").asInstanceOf[dom.html.Canvas] + val ctx = canvas.getContext("2d").asInstanceOf[dom.CanvasRenderingContext2D] + val algebra = Algebra(ctx) + + target.appendChild(canvas) + + def render[A](picture: Picture[A]): IO[A] = { + IO { + val finalized: Finalized[CanvasDrawing, A] = picture(algebra) + val (bb, rdr) = finalized.run(List.empty).value + val drawing = rdr.runA(Transform.identity).value + val a = drawing(ctx) + a + } + } +} +object Canvas { + def fromFrame(frame: Frame): IO[Canvas] = { + IO { + val target = dom.document.getElementById(frame.id) + if (target == null) { + throw new java.util.NoSuchElementException( + s"Doodle Canvas could not be created, as could not find a DOM element with the requested id ${frame.id}" + ) + } else Canvas(target, frame) + } + } +} diff --git a/canvas/src/main/scala/doodle/canvas/effect/CanvasRenderer.scala b/canvas/src/main/scala/doodle/canvas/effect/CanvasRenderer.scala new file mode 100644 index 00000000..26d3fcd5 --- /dev/null +++ b/canvas/src/main/scala/doodle/canvas/effect/CanvasRenderer.scala @@ -0,0 +1,31 @@ +/* + * 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.canvas.effect + +import cats.effect.IO +import doodle.canvas.Algebra +import doodle.canvas.Picture +import doodle.effect.Renderer + +object CanvasRenderer extends Renderer[Algebra, Frame, Canvas] { + + def canvas(description: Frame): IO[Canvas] = + Canvas.fromFrame(description) + + def render[A](canvas: Canvas)(picture: Picture[A]): IO[A] = + canvas.render(picture) +} diff --git a/canvas/src/main/scala/doodle/canvas/effect/Frame.scala b/canvas/src/main/scala/doodle/canvas/effect/Frame.scala new file mode 100644 index 00000000..3a0adaaf --- /dev/null +++ b/canvas/src/main/scala/doodle/canvas/effect/Frame.scala @@ -0,0 +1,56 @@ +/* + * 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.canvas.effect + +import doodle.core.Color + +/** The [[Frame]] specifies how to create the area where the canvas output will + * be drawn. The idiomatic way to create a `Frame` is to start with + * `Frame(anId)`, where `anId` is the id of the DOM element where the output + * should be drawn, and then call the builder methods starting with `with`. + * + * For example, this `Frame` specifies a fixed size and a background color. + * + * ``` + * Frame("canvas").withSize(300, 300).withBackground(Color.midnightBlue) + * ``` + */ +final case class Frame( + id: String, + size: Size, + background: Option[Color] = None +) { + + /** Use the given color as the background. + */ + def withBackground(color: Color): Frame = + this.copy(background = Some(color)) + + /** Size the canvas to fit to the picture's bounding box, plus the given + * border around the bounding box. + */ + def withSizedToPicture(border: Int = 20): Frame = + this.copy(size = Size.fitToPicture(border)) + + /** Size the canvas with the given fixed dimensions. */ + def withSize(width: Double, height: Double): Frame = + this.copy(size = Size.fixedSize(width, height)) +} +object Frame { + def apply(id: String): Frame = + Frame(id, Size.fitToPicture(), None) +} diff --git a/canvas/src/main/scala/doodle/canvas/effect/Size.scala b/canvas/src/main/scala/doodle/canvas/effect/Size.scala new file mode 100644 index 00000000..ab8ed3a0 --- /dev/null +++ b/canvas/src/main/scala/doodle/canvas/effect/Size.scala @@ -0,0 +1,31 @@ +/* + * 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.canvas.effect + +enum Size { + case FitToPicture(border: Int) + case FixedSize(width: Double, height: Double) +} +object Size { + // Smart constructors + + def fitToPicture(border: Int = 20): Size = + Size.FitToPicture(border) + + def fixedSize(width: Double, height: Double): Size = + Size.FixedSize(width, height) +} diff --git a/canvas/src/main/scala/doodle/canvas/package.scala b/canvas/src/main/scala/doodle/canvas/package.scala new file mode 100644 index 00000000..4bd5d908 --- /dev/null +++ b/canvas/src/main/scala/doodle/canvas/package.scala @@ -0,0 +1,23 @@ +/* + * 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.canvas + +import doodle.effect.Renderer + +type Canvas = doodle.canvas.effect.Canvas + +given Renderer[Algebra, Frame, Canvas] = doodle.canvas.effect.CanvasRenderer diff --git a/docs/src/pages/canvas/README.md b/docs/src/pages/canvas/README.md new file mode 100644 index 00000000..682d00fa --- /dev/null +++ b/docs/src/pages/canvas/README.md @@ -0,0 +1,41 @@ +# Doodle Canvas + +## Usage + +Firstly, bring everything into scope + +```scala +import doodle.canvas.{*, given} +``` + +Construct a `Frame` with the `id` of the DOM element where you'd like the picture drawn. + +For example, if you have the following element in your HTML + +``` html +
+``` + +then you can create a `Frame` referring to it with + +``` scala mdoc:silent +val frame = Frame("canvas-root") +``` + +Now suppose you have a picture called `thePicture`. You can draw it using the frame you just created like so + +``` scala +thePicture.drawWithFrame(frame) +``` + +The rendered picture will appear where the element is positioned on your web page. + + +## Examples + +The source for these examples is [in the repository](https://github.com/creativescala/doodle/tree/main/examples/src/main/scala). + + +### Concentric Circles + +@:doodle("concentric-circles", "CanvasConcentricCircles.draw") diff --git a/docs/src/pages/directory.conf b/docs/src/pages/directory.conf index 749b8bd1..9de3ef6b 100644 --- a/docs/src/pages/directory.conf +++ b/docs/src/pages/directory.conf @@ -7,5 +7,6 @@ laika.navigationOrder = [ algebra effect svg + canvas development ] diff --git a/docs/src/pages/svg/README.md b/docs/src/pages/svg/README.md index 7b02e3b2..add2a79a 100644 --- a/docs/src/pages/svg/README.md +++ b/docs/src/pages/svg/README.md @@ -7,7 +7,7 @@ Doodle SVG draws Doodle pictures to SVG, both in the browser using [Scala JS](ht Firstly, bring everything into scope ```scala -import doodle.svg._ +import doodle.svg.* ``` Now what you can do depends on whether you are running in the browser or on the JVM. diff --git a/examples/js/src/main/scala/doodle/examples/canvas/ConcentricCircles.scala b/examples/js/src/main/scala/doodle/examples/canvas/ConcentricCircles.scala new file mode 100644 index 00000000..01de9eff --- /dev/null +++ b/examples/js/src/main/scala/doodle/examples/canvas/ConcentricCircles.scala @@ -0,0 +1,39 @@ +/* + * 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.canvas + +import cats.effect.unsafe.implicits.global +import doodle.canvas.{_, given} +import doodle.core._ +import doodle.syntax.all._ + +import scala.scalajs.js.annotation._ + +@JSExportTopLevel("CanvasConcentricCircles") +object ConcentricCircles { + def circles(count: Int): Picture[Unit] = + if (count == 0) Picture.circle(20).fillColor(Color.hsl(0.degrees, 0.7, 0.6)) + else + Picture + .circle(count.toDouble * 20.0) + .fillColor(Color.hsl((count * 15).degrees, 0.7, 0.6)) + .under(circles(count - 1)) + + @JSExport + def draw(mount: String) = + circles(7).drawWithFrame(Frame(mount)) +}