FS2-D3
FS2-D3 is a pure Scala.js port of D3.js.
It offers a purely-functional (i.e., referentially transparent) and type-safe API that should
feel familiar to users of D3.js.
The library is built on top of Cats Effect and FS2.
While the API is parametric in the effect type F[_]
, in practice most users will want
to use the concrete type cats.effect.IO
.
Acknowledgements
Huge thanks to @armanbilge for answering all of my stupid questions.
Examples
In this first example we generate once per second a random sequence of letters and bind them to SVG text elements. We use transitions to animate the entering, exiting and reordering of letters.
import cats.effect.std.Random
import cats.syntax.all._
import fs2.Stream
import org.scalajs.dom
import concurrent.duration._
import cats.effect.IO
import cats.effect.unsafe.implicits.global
Random.scalaUtilRandom[IO].flatMap { rng =>
val letters = ('a' to 'z').toList.map(_.toString)
val randomLetters = rng.betweenInt(6, 26).flatMap { n =>
rng.shuffleList(letters).map(_.take(n))
}
val w = "400"
val h = "50"
import d3.syntax.svg._
val setup = d3
.select(node)
.append("svg")
.attr(width, w.some)
.attr(height, h.some)
.attr(viewBox, s"0 0 $w $h".some)
.compile
.nodeOrError[IO, dom.Element]
setup.flatMap { svg =>
(Stream.emit(()) ++ Stream
.fixedDelay[IO](1.second))
.evalMap(_ => randomLetters)
.evalMap { data =>
d3.select[IO, dom.Element, Nothing](svg)
.selectAll[dom.Element, String]("text")
.dataKeyed(data)(
(_, d, _, _) => d,
(_, d, _, _) => d
)
.join[IO, dom.Element, String, dom.Element, Nothing](
// enter
_.append[dom.Element]("text")
.attr(fill, "green".some)
.attr(opacity, "1.0".some)
.attr(x)((_, _, i, _) => s"${16 * i}".some)
.attr(y, "0".some)
.text((_, d, _, _) => d)
.transition
.duration(500.millis)
.ease(d3.ease.easeBounce)
.attr(y, "25".some)
.selection,
// update
_.attr(fill, "black".some).transition
.attr(x)((_, _, i, _) => s"${16 * i}".some)
.selection,
// exit
_.attr(fill, "brown".some).transition
.attr(y, "50".some)
.attr(opacity, "0".some)
.remove
.selection
)
.compile
.drain
}
.compile
.drain
}
}.unsafeRunAndForget()
In the next example we randomly place small colored circles on the boundary of a larger circle. Again, we use transitions to animate the changes in position and color.
import cats.effect.std.Random
import cats.syntax.all._
import fs2.Stream
import org.scalajs.dom
import concurrent.duration._
import cats.effect.IO
import cats.effect.unsafe.implicits.global
Random.scalaUtilRandom[IO].flatMap { rng =>
import d3.syntax.svg._
val genData = rng.nextDouble.replicateA(8)
val radius = 100.0
val colors = d3.color.named.keySet.toVector
val nColors = colors.length
val setup = for {
root <- d3
.select(node)
.append("svg")
.attr(width, "300".some)
.attr(height, "300".some)
.compile
.nodeOrError[IO, dom.Element]
_ <- d3
.select(root)
.append("circle")
.attr(style, "fill: none; stroke: #ccc; stroke-dasharray: 1,1".some)
.attr(cx, "150".some)
.attr(cy, "150".some)
.attr(r, s"$radius".some)
.compile
.drain[IO]
g <- d3
.select(root)
.append("g")
.attr(transform, "translate(150, 150)".some)
.compile
.nodeOrError[IO, dom.Element]
} yield g
setup.flatMap { case root =>
Stream
.fixedDelay[IO](1.second)
.evalMap(_ => genData)
.evalMap { data =>
d3.select(root)
.selectAll("circle")
.data(data)
.join[IO, dom.Element, Double, dom.Element, Nothing](
// enter
_.append("circle")
.attr(r, "7".some)
.attr(fill, "gray".some)
.attr(cx, "0".some)
.attr(cy, "0".some)
)
.transition
.delay((_, d, _, _) => (d * 500.0).millis)
.attr(cx)((_, d, _, _) =>
s"${radius * math.cos(d * 2 * math.Pi)}".some
)
.attr(cy)((_, d, _, _) =>
s"${radius * math.sin(d * 2 * math.Pi)}".some
)
.attr(fill)((_, d, _, _) =>
colors(math.min(math.round(d * nColors).toInt, nColors - 1)).some
)
.compile
.drain
}
.compile
.drain
}
}.unsafeRunAndForget()
In this example we add interactivity by registering event listeners.
Clicking on any of the circles will change their color.
Event listeners require an instance of cats.effect.std.Dispatcher[F]
,
and since the listener outlives the effect of registering it,
we need to keep the dispatcher alive until the listener is removed again,
which in this case is never.
import cats.effect.std.Dispatcher
import cats.syntax.all._
import org.scalajs.dom
import concurrent.duration._
import cats.effect.IO
import cats.effect.unsafe.implicits.global
Dispatcher.parallel[IO].use { dispatcher =>
val data = List("1", "2", "3")
val w = "150"
val h = "50"
import d3.syntax.html.cursor
import d3.syntax.svg._
val setup = d3
.select(node)
.append[dom.Element]("svg")
.attr(width, w.some)
.attr(height, h.some)
.attr(viewBox, s"0 0 $w $h".some)
.compile
.nodeOrError[IO, dom.Element]
setup.flatMap { svg =>
d3.select(svg)
.selectAll("circle")
.data(data)
.join[IO, dom.Element, String, dom.Element, Nothing](
// enter
_.append[dom.Element]("circle")
.attr(r, "10".some)
.attr(fill, "gray".some)
.attr(cx)((_, _, i, _) => s"${50 * i + 25}".some)
.attr(cy, "25".some)
.style(cursor.pointer)
.on[IO](
"click",
Some((n: dom.Element, _: dom.Event, _: String) =>
d3.select(n).compile.attr[IO]("fill").flatMap { fill0 =>
val currentColor = fill0.flatMap { f =>
d3.color.fromString(f)
}
val color1 = d3.color.fromString("green").get
val color2 = d3.color.fromString("red").get
val newFill =
if (currentColor.exists(_ == color1)) color2 else color1
d3.select(n)
.transition
.attr(r, "15".some)
.attr(fill, newFill.toString.some)
.transition
.duration(250.millis)
.attr(r, "10".some)
.compile
.drain[IO]
}
),
None,
dispatcher
)
)
.compile
.drain
} >> IO.never // we need `never` here to keep the dispatcher alive
}.unsafeRunAndForget()