Integrate JavaScript libraries with ScalablyTyped

In this third tutorial, we learn how to integrate JavaScript libraries with ScalablyTyped.

We start here with the project developed in the previous tutorial about UI development with Laminar. To follow along this tutorial, either use the result of the previous tutorial, or checkout the laminar-end-state branch of the accompanying repo.

If you prefer to look at the end result for this tutorial directly, checkout the scalablytyped-end-state branch instead.

There are no playgrounds in Scribble for this tutorial because it does not support ScalablyTyped. You will need a local project to follow along.

Prerequisites

Make sure to install the prerequisites before continuing further.

Set up ScalablyTyped with Chart.js

We will use Chart.js, a JavaScript library, to draw a bar chart out of the shopping list data. In order to get static types and bindings for Chart.js, we use ScalablyTyped. ScalablyTyped can read TypeScript type definition files and produce corresponding Scala.js facade types.

We set up our new dependencies as follows.

First, we install some npm packages: Chart.js as a regular dependency (with -S), and its TypeScript type definitions along with the TypeScript compiler—required by ScalablyTyped—as development dependencies (with -D):

$ npm install -S [email protected]
...
$ npm install -D @types/[email protected] [email protected]
...

In project/plugins.sbt, we add a dependency on ScalablyTyped:

addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta41")

Finally, in build.sbt, we configure ScalablyTyped on our project:

 lazy val livechart = project.in(file("."))
   .enablePlugins(ScalaJSPlugin) // Enable the Scala.js plugin in this project
+  .enablePlugins(ScalablyTypedConverterExternalNpmPlugin)
   .settings(
     scalaVersion := "3.2.2",
     [...]
     // Testing framework
     libraryDependencies += "org.scalameta" %%% "munit" % "0.7.29" % Test,
+
+    // Tell ScalablyTyped that we manage `npm install` ourselves
+    externalNpm := baseDirectory.value,
   )

For these changes to take effect, we have to perform the following steps:

  1. Restart sbt and the ~fastLinkJS task (this will take a while the first time, as ScalablyTyped performs its magic)
  2. Restart npm run dev if it was running
  3. Possibly re-import the project in your IDE of choice

Chart configuration

We can now enjoy Chart.js with static types in our Scala.js code.

First, we define the Chart.js configuration that we will use:

  val chartConfig =
    import typings.chartJs.mod.*
    new ChartConfiguration {
      `type` = ChartType.bar
      data = new ChartData {
        datasets = js.Array(
          new ChartDataSets {
            label = "Price"
            borderWidth = 1
            backgroundColor = "green"
          },
          new ChartDataSets {
            label = "Full price"
            borderWidth = 1
            backgroundColor = "blue"
          }
        )
      }
      options = new ChartOptions {
        scales = new ChartScales {
          yAxes = js.Array(new CommonAxe {
            ticks = new TickOptions {
              beginAtZero = true
            }
          })
        }
      }
    }
  end chartConfig

At the top, we import the facade types for Chart.js generated by ScalablyTyped:

import typings.chartJs.mod.*

This gives us access to types like ChartConfiguration and ChartType.

Inside the ChartConfiguration, we provide a number of Chart.js-related options to make our chart look the way we want:

  • the type of chart as a bar chart: type = ChartType.bar,
  • two datasets with the labels "Price" and "Full price", respectively, and
  • the y axis’ start value as 0.

All of these configuration options are type-checked, using the static types provided by ScalablyTyped. If written in JavaScript, the above configuration would read as:

{
  type: "bar",
  data: {
    datasets: [
      {
        label: "Price",
        borderWidth: 1,
        backgroundColor: "green"
      },
      {
        label: "Full price",
        borderWidth: 1,
        backgroundColor: "blue"
      }
    ]
  },
  options: {
    scales: {
      yAxes: [{
        ticks: {
          beginAtZero: true
        }
      }]
    }
  }
}

In a sense, that is all there is to know about ScalablyTyped. What follows is more about the integration of a third-party “component” into Laminar than anything else.

Rendering the chart

We now amend our appElement() method to also call a new renderDataChart() function:

  def appElement(): Element =
     div(
       h1("Live Chart"),
       renderDataTable(),
+      renderDataChart(),
       renderDataList(),
     )
   end appElement

The implementation of renderDataChart() is rather large. We show it in its entirety first, then we will pick it apart.

  def renderDataChart(): Element =
    import scala.scalajs.js.JSConverters.*
    import typings.chartJs.mod.*

    var optChart: Option[Chart] = None

    canvasTag(
      // Regular properties of the canvas
      width := "100%",
      height := "200px",

      // onMountUnmount callback to bridge the Laminar world and the Chart.js world
      onMountUnmountCallback(
        // on mount, create the `Chart` instance and store it in optChart
        mount = { nodeCtx =>
          val domCanvas: dom.HTMLCanvasElement = nodeCtx.thisNode.ref
          val chart = Chart.apply.newInstance2(domCanvas, chartConfig)
          optChart = Some(chart)
        },
        // on unmount, destroy the `Chart` instance
        unmount = { thisNode =>
          for (chart <- optChart)
            chart.destroy()
          optChart = None
        }
      ),

      // Bridge the FRP world of dataSignal to the imperative world of the `chart.data`
      dataSignal --> { data =>
        for (chart <- optChart) {
          chart.data.labels = data.map(_.label).toJSArray
          chart.data.datasets.get(0).data = data.map(_.price).toJSArray
          chart.data.datasets.get(1).data = data.map(_.fullPrice).toJSArray
          chart.update()
        }
      },
    )
  end renderDataChart

We create a Laminar canvasTag() element. We give it a width and height using Laminar’s :=, as we did before.

For its actual content, we want Chart.js to take over. For that, we have to create an instance of Chart referencing the DOM HTMLCanvasElement. In order to bridge the world of Laminar Elements and Chart.js, we use onMountUnmountCallback.

That function takes one callback executed when the element is attached to a DOM tree, and one when it is removed. When the element is mounted, we want to create the instance of Chart. When it is unmounted, we want to call the destroy() method of Chart.js to release its resources.

The mount callback receives a nodeCtx, which, among other things, gives us a handle to the underlying HTMLCanvasElement. We name it domCanvas, and use it together with the chartConfig defined above to create an instance of Chart.js’ Chart class:

val domCanvas: dom.HTMLCanvasElement = nodeCtx.thisNode.ref
val chart = Chart.apply.newInstance2(domCanvas, chartConfig)

We store the resulting chart instance in a local var optChart: Option[Chart]. We will use it later to update the chart’s imperative data model when our FRP dataSignal changes.

In order to achieve that, we use a dataSignal --> binder. We give it as an argument to the Laminar canvasTag element to tie the binder to the canvas lifetime, as you may recall from the Laminar tutorial. Once the canvas gets mounted, every time the value of dataSignal changes, the callback is executed.

dataSignal --> { data =>
  for (chart <- optChart) {
    chart.data.labels = data.map(_.label).toJSArray
    chart.data.datasets.get(0).data = data.map(_.price).toJSArray
    chart.data.datasets.get(1).data = data.map(_.fullPrice).toJSArray
    chart.update()
  }
},

In the callback, we get access to the chart: Chart instance and update its data model. This --> binder allows to bridge the FRP world of dataSignal with the imperative world of Chart.js.

Our application now properly renders the data model as a chart. When we add or remove data items, the chart is automatically updated, thanks to the connection established by the dataSignal --> binder.

Conclusion

That concludes our tutorial on ScalablyTyped.

We saw how to configure ScalablyTyped to get static types for external JavaScript libraries, and how to integrate a third-party component into a Laminar model.