Skip to content

Instantly share code, notes, and snippets.

@lbialy
Created December 31, 2021 11:17
Show Gist options
  • Save lbialy/d51d41af55a275d094d45a99c24db932 to your computer and use it in GitHub Desktop.
Save lbialy/d51d41af55a275d094d45a99c24db932 to your computer and use it in GitHub Desktop.
GraalVM Polyglot-based Chart.js renderer for Scala + ZIO
// JDK = OpenJDK 64-Bit Server VM GraalVM CE 21.3.0
libraryDependencies ++= Seq(
"org.graalvm.sdk" % "graal-sdk" % "21.3.0" % Provided,
"org.apache.xmlgraphics" % "batik-transcoder" % "1.14",
"org.apache.xmlgraphics" % "batik-codec" % "1.14",
"dev.zio" %% "zio" % "1.0.12",
"dev.zio" %% "zio-streams" % "1.0.12",
"ch.qos.logback" % "logback-classic" % "1.2.10",
)
package analytics
import analytics.ChartJs.ChartJsConfig
import logging.loggerStream
import org.apache.batik.transcoder.{SVGAbstractTranscoder, TranscoderInput, TranscoderOutput}
import org.apache.batik.transcoder.image.{ImageTranscoder, PNGTranscoder}
import org.graalvm.polyglot.{Context, EnvironmentAccess, HostAccess, PolyglotAccess, Source, Value}
import org.slf4j.LoggerFactory
import zio.blocking.Blocking
import zio.stream._
import zio._
import java.awt.Color
import java.io.{ByteArrayInputStream, OutputStream}
import java.nio.file.Paths
import java.time.ZoneId
class ChartJs private (context: Context, renderFunc: Value) {
def render(config: ChartJsConfig): Task[String] = Task {
val preparedOpts = productToJsObj(config)
renderFunc.execute(preparedOpts).asString()
}
def transcodeToPNG(svg: String): ZStream[Blocking, Throwable, Byte] = {
ZStream.fromOutputStreamWriter(os => {
val ti = new TranscoderInput(new ByteArrayInputStream(svg.getBytes()))
val to = new TranscoderOutput(os)
val t = new PNGTranscoder()
t.addTranscodingHint(SVGAbstractTranscoder.KEY_WIDTH, java.lang.Float.valueOf(1600f))
t.addTranscodingHint(ImageTranscoder.KEY_BACKGROUND_COLOR, Color.white)
t.transcode(ti, to)
})
}
// it's a PoC, don't judge me
private def productToJsObj(p: Product): Value =
p.productElementNames.zip(p.productIterator).foldLeft(createObject) { case (obj, (name, value)) =>
val encodedValue = value match {
case iterable: Iterable[_] => arrayFromIterable(iterable)
case nestedProduct: Product => productToJsObj(nestedProduct)
case _ => value
}
obj.putMember(name, encodedValue)
obj
}
private def createObject: Value = context.eval("js", "Object.create({})")
private def arrayFromIterable(i: Iterable[_]): Value = {
val arr = context.eval("js", "new Array();")
i.zipWithIndex.foreach { case (a, idx) =>
arr.setArrayElement(
idx,
a match {
case iterable: Iterable[_] => arrayFromIterable(iterable)
case nestedProduct: Product => productToJsObj(nestedProduct)
case _ => a
},
)
}
arr
}
}
object ChartJs {
private val logger = LoggerFactory.getLogger(classOf[ChartJs])
private def spawnContext(out: OutputStream, err: OutputStream): Managed[Throwable, Context] =
ZManaged.fromAutoCloseable {
Task {
Context
.newBuilder()
.allowCreateProcess(false)
.allowCreateThread(false)
.allowPolyglotAccess(PolyglotAccess.NONE)
.allowHostAccess(HostAccess.NONE)
.allowHostClassLoading(false)
.allowIO(false)
.allowNativeAccess(false)
.allowValueSharing(false)
.currentWorkingDirectory(Paths.get("/tmp"))
.allowEnvironmentAccess(EnvironmentAccess.NONE)
.timeZone(ZoneId.of("Europe/Warsaw"))
.options(new java.util.HashMap[String, String] {
put("js.intl-402", "true")
})
.out(out)
.err(err)
.build()
}
}
val Live: ZLayer[Any, Throwable, Has[ChartJs]] = {
val out = loggerStream(logger.info)
val err = loggerStream(logger.error)
spawnContext(out, err).map { context =>
val src = Source.newBuilder("js", ClassLoader.getSystemClassLoader.getResource("chart.js")).build()
val renderFunc = context.eval(src).execute(1)
new ChartJs(context, renderFunc)
}.toLayer
}
case class Dataset(
label: String,
data: List[Double],
backgroundColor: List[String],
borderColor: List[String],
borderWidth: Double,
)
case class ChartData(labels: List[String], datasets: List[Dataset])
case class ScaleOptions(beginAtZero: Boolean)
case class Scales(y: ScaleOptions)
case class Layout(padding: Double)
case class ChartOptions(scales: Scales, layout: Layout)
case class ChartJsConfig(`type`: String, data: ChartData, options: ChartOptions)
}
// shamelessly adapted from https://github.com/shellyln/chart.js-node-ssr-example
const { SvgCanvas, Rect2D, SvgCanvas2DGradient, TransferMatrix2D } = require('red-agate-svg-canvas/modules');
const ChartJs = require('chart.js');
const registerables = ChartJs.registerables
ChartJs.Chart.register(...registerables);
// Get the global scope.
// If running on a node, "g" points to a "global" object.
// When running on the browser, "g" points to the "window" object.
const g = Function('return this')();
module.exports = function(opts) {
// SvgCanvas has a "CanvasRenderingContext2D"-compatible interface.
const ctx = new SvgCanvas();
// SvgCanvas lacks the canvas property.
ctx.canvas = {
width: 800,
height: 400,
style: {
width: '800px',
height: '400px',
},
};
ctx.resetTransform = function () {
this.currentPointOnCtm = null;
this.ctm = new TransferMatrix2D();
};
// SvgCanvas does not have font glyph information,
// so manually set the ratio of (font height / font width).
ctx.fontHeightRatio = 2;
// Chart.js needs a "HTMLCanvasElement"-like interface that has "getContext()" method.
// "getContext()" should returns a "CanvasRenderingContext2D"-compatible interface.
const el = { getContext: () => ctx };
// If "devicePixelRatio" is not set, Chart.js get the devicePixelRatio from "window" object.
// node.js environment has no window object.
opts.options.devicePixelRatio = 1;
// Disable animations.
opts.options.animation = false;
opts.options.events = [];
opts.options.responsive = false;
// Chart.js needs the "CanvasGradient" in the global scope.
const savedGradient = g.CanvasGradient;
g.CanvasGradient = SvgCanvas2DGradient;
try {
const chart = new ChartJs.Chart(el, opts);
} finally {
if (savedGradient) {
g.CanvasGradient = savedGradient;
}
}
// Render as SVG.
return ctx.render(new Rect2D(0, 0 , 800, 400), 'px');
}
object logging {
def loggerStream(outputLine: String => Unit): OutputStream = {
val output = new PipedOutputStream()
val input = new PipedInputStream(output)
val thread = new Thread(() => {
scala.io.Source.fromInputStream(input)(Codec.UTF8).getLines().foreach(outputLine)
})
thread.setDaemon(true)
thread.start()
output
}
}
{
"name": "charts",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"bundle": "./node_modules/browserify/bin/cmd.js index.js -p esmify > ../src/main/resources/chart.js"
},
"author": "Łukasz Biały",
"license": "ISC",
"dependencies": {
"chart.js": "^3.7.0",
"red-agate-svg-canvas": "^0.5.0",
"red-agate-util": "^0.5.0"
},
"devDependencies": {
"browserify": "^17.0.0",
"esmify": "^2.1.1"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment