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