From be46f1de8031c38def2017930f3a719ece0725e0 Mon Sep 17 00:00:00 2001 From: Martin Ring <martin.ring@encoway.de> Date: Mon, 13 Jun 2022 07:10:52 +0200 Subject: [PATCH] add paint example --- lecture-08/build.sbt | 23 ++++ lecture-08/index.html | 11 ++ lecture-08/js/Main.scala | 189 ++++++++++++++++++++++++++++ lecture-08/jvm/Streams.scala | 32 +++++ lecture-08/project/build.properties | 1 + lecture-08/project/plugins.sbt | 2 + lecture-08/shared/Geometry.scala | 56 +++++++++ lecture-08/shared/Paint.scala | 30 +++++ 8 files changed, 344 insertions(+) create mode 100644 lecture-08/build.sbt create mode 100644 lecture-08/index.html create mode 100644 lecture-08/js/Main.scala create mode 100644 lecture-08/jvm/Streams.scala create mode 100644 lecture-08/project/build.properties create mode 100644 lecture-08/project/plugins.sbt create mode 100644 lecture-08/shared/Geometry.scala create mode 100644 lecture-08/shared/Paint.scala diff --git a/lecture-08/build.sbt b/lecture-08/build.sbt new file mode 100644 index 0000000..b595364 --- /dev/null +++ b/lecture-08/build.sbt @@ -0,0 +1,23 @@ +ThisBuild / scalaVersion := "3.1.2" + +lazy val root = project.in(file(".")) + .aggregate(lecture08.js, lecture08.jvm) + .settings( + publish := {}, + publishLocal := {} + ) + +lazy val lecture08 = crossProject(JSPlatform,JVMPlatform).in(file(".")) + .settings( + Compile / scalaSource := baseDirectory.value, + name := "lecture-08", + ) + .jvmSettings( + libraryDependencies += ("com.typesafe.akka" %% "akka-stream" % "2.6.14").cross(CrossVersion.for3Use2_13) + //libraryDependencies += "org.scala-js" %% "scalajs-dom" % "2.2.0" + ) + .jsSettings( + libraryDependencies += ("org.akka-js" %%% "akkajsactorstream" % "2.2.6.14").cross(CrossVersion.for3Use2_13), + libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "2.2.0", + scalaJSUseMainModuleInitializer := true + ) \ No newline at end of file diff --git a/lecture-08/index.html b/lecture-08/index.html new file mode 100644 index 0000000..8182bea --- /dev/null +++ b/lecture-08/index.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=devide-width, initial-scale=1.0"> + <title>Reactive Paint</title> +</head> +<body> + <script src="target/scala-3.1.2/lecture-08-fastopt.js"></script> +</body> +</html> \ No newline at end of file diff --git a/lecture-08/js/Main.scala b/lecture-08/js/Main.scala new file mode 100644 index 0000000..180fb8b --- /dev/null +++ b/lecture-08/js/Main.scala @@ -0,0 +1,189 @@ +import akka.NotUsed + +import akka.actor._ +import akka.stream._ +import akka.stream.scaladsl._ +import org.scalajs.dom._ +import org.scalajs.dom.ext.KeyCode +import org.scalajs.dom.EventListenerOptions + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.{Future, Promise} + +implicit class EventSource(val target: org.scalajs.dom.EventTarget): + def on[E <: Event](event: String): Source[E, NotUsed] = + Source.queue[E](2048, OverflowStrategy.fail).mapMaterializedValue { queue => + val handler: scalajs.js.Function1[E, Unit] = (e: E) => queue.offer(e) + target.addEventListener(event, handler) + handler + }.watchTermination() { + case (h, d) => + d.onComplete(_ => target.removeEventListener(event, h)) + NotUsed + } + +@main def main(): Unit = + import PaintCommand._ + given system: ActorSystem = ActorSystem("paint") + given Materializer = Materializer(system) + + val canvas = document.createElement("canvas").asInstanceOf[html.Canvas] + document.body.appendChild(canvas) + canvas.style.position = "fixed" + canvas.style.left = "0" + canvas.style.right = "0" + canvas.style.top = "0" + canvas.style.bottom = "0" + + canvas.width = window.innerWidth.toInt + canvas.height = window.innerHeight.toInt + + val mouseDown = + canvas.on[MouseEvent]("mousedown") + .map(e => BeginDrag(e.clientX, e.clientY)) + + val mouseUp = + canvas.on[MouseEvent]("mouseup") + .map(e => EndDrag(e.clientX, e.clientY)) + + val mouseLeave = + canvas.on[MouseEvent]("mouseleave") + .map(e => EndDrag(e.clientX, e.clientY)) + + val mouseMove = + canvas.on[MouseEvent]("mousemove") + .map(e => Drag(e.clientX, e.clientY)) + + val mouseCommands = mouseDown.flatMapConcat { b => + Source.single(b).concat( + mouseMove.merge(mouseUp merge mouseLeave).takeWhile(_.isInstanceOf[Drag], inclusive = true) + ) + } + + val keyboardCommands = + window.on[KeyboardEvent]("keydown") + .map(e => e.keyCode) + .collect { + case KeyCode.B => ChooseColor("blue") + case KeyCode.G => ChooseColor("green") + case KeyCode.R => ChooseColor("red") + case KeyCode.Up => IncStrokeWidth + case KeyCode.Down => DecStrokeWidth + case KeyCode.Left => SelectPrev + case KeyCode.Right => SelectNext + case KeyCode.Backspace => Cut + case KeyCode.Enter => Paste + case KeyCode.C => Copy + } + + val commands: Source[PaintCommand, NotUsed] = + mouseCommands merge keyboardCommands + + val paint: Flow[PaintCommand, PaintState, NotUsed] = + Flow[PaintCommand].scan(PaintState.initial) { + case (state, ChooseColor(c)) => + if (state.isSelected) state.modifySelected(_.copy(color = c)) + else state.copy(color = c) + case (state, IncStrokeWidth) => + if (state.isSelected) state.modifySelected(p => p.copy(width = p.width + 1)) + else state.copy(width = state.width + 1) + case (state, DecStrokeWidth) => + if (state.isSelected) state.modifySelected(p => p.copy(width = Math.max(1,p.width - 1))) + else state.copy(width = Math.max(1,state.width - 1)) + case (state, BeginDrag(x, y)) => + val s = state.paths.indexWhere(_.hits(x,y)) + if (s > -1) state.copy( + selected = s, + dragStart = (x,y) + ) else state.copy( + selected = -1, + currentPath = Some(Path(state.color, state.width, Vector(Point(x, y))))) + case (state, Drag(x, y)) => + if (state.isSelected) state.modifySelected(p => p.copy(points = p.points.map { + case Point(px,py) => Point(px + x - state.dragStart._1,py + y - state.dragStart._2) + })).copy(dragStart = (x,y)) else state.copy( + currentPath = state.currentPath.map(_.append(x, y))) + case (state, EndDrag(x, y)) => + if (state.isSelected) state.modifySelected(p => p.copy(points = p.points.map { + case Point(px,py) => Point(px + x - state.dragStart._1,py + y - state.dragStart._2) + })) else state.copy( + paths = state.currentPath.filter(!_.isEmpty).map(_.append(x,y)).toList ++ state.paths, + currentPath = None) + case (state, SelectPrev) => + if (state.selected > 0 && state.selected < state.paths.length) { + state.copy(selected = state.selected - 1) + } else state.copy(selected = state.paths.length - 1) + case (state, SelectNext) => + if (state.selected > -1 && state.selected < state.paths.length - 1) { + state.copy(selected = state.selected + 1) + } else state.copy(selected = 0) + case (state, Copy) => + if (state.isSelected) state.copy( + clipboard = Some(state.paths(state.selected))) + else state + case (state, Cut) => + if (state.isSelected) { + val (before,deleted :: after) = state.paths.splitAt(state.selected) + state.copy( + paths = before ++ after, + clipboard = Some(deleted), + selected = -1 + ) + } else state + case (state, Paste) => + if (state.clipboard.isDefined) state.copy( + paths = state.clipboard.toList ++ state.paths, + selected = 0 + ) else state + } + + def visualize = Sink.foreach[PaintState] { + case s@PaintState(color, width, paths, _, currentPath, selected, _) => + val context = canvas.getContext("2d").asInstanceOf[raw.CanvasRenderingContext2D] + context.lineJoin = "round" + context.lineCap = "round" + context.clearRect(0,0,canvas.width,canvas.height) + paths.zipWithIndex.reverse.foreach { + case (p@Path(color, width, points),i) => + if (i == selected) { + context.shadowColor = "blue" + context.shadowBlur = width + } + context.strokeStyle = color + context.lineWidth = width + context.beginPath() + p.controlPoints.headOption.foreach { + case Point(x, y) => context.moveTo(x, y) + } + p.controlPoints.drop(1).grouped(3).foreach { + case Seq(c1,c2,p) => context.bezierCurveTo(c1.x,c1.y,c2.x,c2.y,p.x,p.y) + } + context.stroke() + context.shadowColor = null + context.shadowBlur = 0 + } + currentPath.foreach { + case Path(color, width, points) => + context.strokeStyle = color + context.lineWidth = width + context.beginPath() + points.headOption.foreach { + case Point(x, y) => context.moveTo(x, y) + } + points.drop(1).foreach { + case Point(x, y) => context.lineTo(x, y) + } + context.stroke() + } + context.shadowColor = null + context.shadowBlur = 0 + // visualize state + context.strokeStyle = color + context.lineWidth = width + context.beginPath() + context.moveTo(10 + width / 2,10 + width) + context.lineTo(10 + width / 2,50) + context.stroke() + } + + (commands via paint to visualize).run() \ No newline at end of file diff --git a/lecture-08/jvm/Streams.scala b/lecture-08/jvm/Streams.scala new file mode 100644 index 0000000..b619458 --- /dev/null +++ b/lecture-08/jvm/Streams.scala @@ -0,0 +1,32 @@ +import akka.actor.ActorSystem +import akka.stream.Materializer +import akka.stream.scaladsl._ +import scala.concurrent.Future +import scala.concurrent.ExecutionContext +import akka.NotUsed +import scala.concurrent.duration._ +import akka.stream.OverflowStrategy + +given system: ActorSystem = ActorSystem("paint") +given ExecutionContext = system.dispatcher +given Materializer = Materializer(system) + +def sum = + val source1 = Source.tick(1.second,1.second,0) + val source2 = Source(Stream.iterate(0)(_ + 1)) + val source = source1.zip(source2) + val sink = Sink.foreach[Any](i => println("step: " + i)) + val sum = source runWith sink + sum + +def error1 = + val source = Source(0 to 5).map(100 / _) + val result = source.runWith(Sink.fold(0)(_ + _)) + result + +def error2 = + val source = Source(0 to 5).map(100 / _).recover { + case _: ArithmeticException => 0 + } + val result = source.runWith(Sink.fold(0)(_ + _)) + result \ No newline at end of file diff --git a/lecture-08/project/build.properties b/lecture-08/project/build.properties new file mode 100644 index 0000000..c8fcab5 --- /dev/null +++ b/lecture-08/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.6.2 diff --git a/lecture-08/project/plugins.sbt b/lecture-08/project/plugins.sbt new file mode 100644 index 0000000..82dde6b --- /dev/null +++ b/lecture-08/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.10.0") +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0") \ No newline at end of file diff --git a/lecture-08/shared/Geometry.scala b/lecture-08/shared/Geometry.scala new file mode 100644 index 0000000..0060cce --- /dev/null +++ b/lecture-08/shared/Geometry.scala @@ -0,0 +1,56 @@ +case class Point( + x: Double, + y: Double +): + def +(that: Point) = Point(this.x + that.x, this.y + that.y) + def unary_- : Point = Point(-x,-y) + def -(that: Point) = this + (- that) + def *(scalar: Double) = Point(x*scalar,y*scalar) + def magnitude: Double = Math.sqrt(Math.pow(x,2) + Math.pow(y,2)) + def phi: Double = Math.atan2(y,x) + def normalized: Point = Point(Math.cos(phi),Math.sin(phi)) + +object Point: + def distance(a: Point, b: Point): Double = (a - b).magnitude + +case class Path( + color: String, + width: Int, + points: Vector[Point] +): + def append(x: Double, y: Double): Path = { + if (points.length > 1 && Point.distance(points.last,points.init.last) <= width * 2) + copy(points = points.init :+ Point(x,y)) + else + copy(points = points :+ Point(x,y)) + } + + val scale = 0.5 + + lazy val controlPoints: Seq[Point] = (for (i <- 0 until points.length) yield { + if (i == 0) { + val p1 = points(i) + val p2 = points(i+1) + val tangent = p2 - p1; + val q1 = p1 + tangent * scale; + Seq(p1,q1) + } else if (i == points.length - 1) { + val p0 = points(i - 1) + val p1 = points(i) + val tangent = (p1 - p0); + val q0 = p1 - tangent * scale; + Seq(q0,p1) + } else { + val p0 = points(i - 1) + val p1 = points(i) + val p2 = points(i + 1) + val tangent = (p2 - p0).normalized; + val q0 = p1 - tangent * scale * (p1 - p0).magnitude; + val q1 = p1 + tangent * scale * (p2 - p1).magnitude; + Seq(q0,p1,q1) + } + }).flatten + + def hits(x: Double, y: Double): Boolean = points.exists(p => Point.distance(p,Point(x,y)) <= width) + + def isEmpty: Boolean = points.length < 2 diff --git a/lecture-08/shared/Paint.scala b/lecture-08/shared/Paint.scala new file mode 100644 index 0000000..b67bf62 --- /dev/null +++ b/lecture-08/shared/Paint.scala @@ -0,0 +1,30 @@ +case class PaintState( + color: String, + width: Int, + paths: List[Path], + clipboard: Option[Path], + currentPath: Option[Path], + selected: Int, + dragStart: (Double,Double) +): + def isSelected: Boolean = selected > -1 && selected < paths.length + def modifySelected(f: Path => Path): PaintState = { + val (before,s :: after) = paths.splitAt(selected) + copy(paths = before ++ (f(s) :: after)) + } + +object PaintState: + def initial = PaintState("black",5,List.empty,Option.empty,None,-1,(0,0)) + +enum PaintCommand: + case ChooseColor(color: String) + case IncStrokeWidth + case DecStrokeWidth + case BeginDrag(x: Double, y: Double) + case Drag(x: Double, y: Double) + case EndDrag(x: Double, y: Double) + case Cut + case Copy + case Paste + case SelectNext + case SelectPrev \ No newline at end of file -- GitLab