Created
December 31, 2021 11:17
-
-
Save lbialy/d51d41af55a275d094d45a99c24db932 to your computer and use it in GitHub Desktop.
GraalVM Polyglot-based Chart.js renderer for Scala + ZIO
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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", | |
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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'); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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