How to write the diagrams

You can create aquascapes yourself by using the aquascape library.

If you're desigining a program with fs2, a custom aquascape is a good tool for understanding how it behaves.

If you write fs2 related technical articles, presentations or documentation, you can use an aquascape to enhance them.

Quick start

Extend AquascapeApp:

// App.scala
//> using dep com.github.zainab-ali::aquascape::0.3.0

import aquascape.*
import cats.effect.*
import fs2.*

object App extends AquascapeApp {

  def name: String = "aquascapeFrame" // The name of the png file (Scala) or HTML frame id (Scala.js)
  
  def stream(using Scape[IO]): IO[Unit] = {
    Stream(1, 2, 3)
      .stage("Stream(1, 2, 3)")       // `stage` introduces a stage.
      .evalMap(x => IO(x).trace())    // `trace` traces a side effect.
      .stage("evalMap(…)")
      .compile
      .toList
      .compileStage("compile.toList") // `compileStage` is used for the final stage.
      .void
  }
}

Write a PNG image file

Run the app with Scala 3.5.0 and above:

scala run App.scala

Congratulations! You've produced an aquascapeFrame.png aquascape.

Embed an SVG in HTML

Package the app with Scala 3.5.0 and above to produce a App.js file.

scala --power package --js-version 1.16.0 --js App.scala

Include this as a script:

<html>
  <head>
    <script src="App.js" type="text/javascript"></script>
  </head>
  <body>
    <div id="aquascapeFrame">
  </body>
</html>

Open up your HTML page to see your aquascape.

How to draw chunks

By default, the AquascapeApp purposefully uses singleton chunks, and hides them from the generated images. This lets us pretend that a single element is pulled and outputted.

If you want to investigate chunk preservation properties, you can leave chunks as they are and display them by overriding the chunked function.

import aquascape.*
import cats.effect.*

object App extends AquascapeApp {
  def name: String = "aquascapeFrame"
  override def chunked: Boolean = true
  def stream(using Scape[IO]) = ???
}

How to draw concurrent processes

Aquascape can track most pulls and outputs by itself. It needs a bit of manual intervention for operators requiring Concurrent.

To achieve this, we introduce the concept of a branch. A branch is a portion of the scape that behaves sequentially.

The fork function relates two branches to each other. It must be inserted directly before each Concurrent operator.

As an example, parEvalMap requires a Concurrent instance:

import fs2.*
import cats.syntax.all.*

object App extends AquascapeApp {
  def name: String = "aquascapeFrame"
  def stream(using Scape[IO]) = {
    Stream('a', 'b', 'c')
      .stage("Stream('a','b','c')", "upstream") // This stage is part of the `upstream` branch.
      .fork("root", "upstream")                 // Relate the `root` branch to the `upstream` branch.
      .parEvalMap(2)(_.pure[IO].trace())
      .stage("parEvalMap(2)(…)")                // This stage is part of the default `root` branch.
      .compile
      .drain
      .compileStage("compile.drain")            // Introduce a default branch named `root`.
  }
}

Best practices

A good aquascape is a simple, informative diagram. It helps readers understand how a stream system behaves.

Unfortunately, aquascapes can easily become too complex to follow.

Stick to these best practices to generate good aquascapes:

Good luck, and enjoy aquascaping!