VegaPlot
The concept is that the macro parses some JSON template which represents a visualization spec (in vega / vega-lite / echarts), that is close to the plot you wish to draw. The spec is assumed to be incomplete - it may be missing data for example that is generated at runtime.
The macro parses the JSON object and generates
- setters for every field that match the JSON AST provided - e.g. title would usually be a String.
- setters which accept a circe
Jsonvalue. This is the escape hatch - you can put anything in here. - Under the hood, it takes advantages of circe's optics module
Example
Let's make a bar chart. The vega-lite spec for a bar chart is here. It is recommended to follow these examples along in a repl.
import io.github.quafadas.plots.SetupVegaBrowser.{*, given}
import io.circe.syntax.*
val barChart = VegaPlot.fromString("""{
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"description": "A simple bar chart with embedded data.",
"data": {
"values": [
{"a": "A", "b": 28}, {"a": "B", "b": 55}, {"a": "C", "b": 43}
]
},
"mark": "bar",
"encoding": {
"x": {"field": "a", "type": "nominal", "axis": {"labelAngle": 0}},
"y": {"field": "b", "type": "quantitative"}
}
}""")
We can set some trivial property.
barChart.plot(
_description := "Redescribed...",
)
Note that if we try to set a title:
barChart.plot(
_.title := "My Bar Chart"
)
This is a compile error because there is no title field in the JSON spec. If we put the title field in the JSON spec:
import io.github.quafadas.plots.SetupVegaBrowser.{*, given}
import io.circe.syntax.*
val barChart = VegaPlot.fromString("""{
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"description": "A simple bar chart with embedded data.",
"title": "My Bar Chart",
"data": {
"values": [
{"a": "A", "b": 28}, {"a": "B", "b": 55}, {"a": "C", "b": 43}
]
},
"mark": "bar",
"encoding": {
"x": {"field": "a", "type": "nominal", "axis": {"labelAngle": 0}},
"y": {"field": "b", "type": "quantitative"}
}
}""")
And give it another go
barChart.plot(
_.title := "With title"
)
Then it will accept it.
Also: at every node in the JSON AST, VegaPlot will accept circe's Json type. For example, Vega accepts a broader set of properties for title other than just text.
But this will accept arbitrary JSON.
import io.circe.literal.*
barChart.plot(
_.title := json"""{"text": "A bigger title", "fontSize": 30}"""
)
This will fail - nottitle is not a valid field.
import io.circe.literal.*
barChart.plot(
_.nottitle := json"""{"text": "A bigger title", "fontSize": 30}"""
)
We can take advantage of this, and the neatness of scala's NamedTuple, to set more or less anything we want in an anonymous, convienient manner.
barChart.plot(
_.data.values := List(
(a = "A", b = 10),
(a = "B", b = 20),
(a = "C", b = 30)
).asJson,
_.title := (text = "Custom Data", fontSize = 25).asJson
)
This exposes the entire oportunity set of vega / lite in a reasonably convienient manner.
One can easily build fairly robust, typesafe visualiations on top of this small set of abstractions.
Sometimes, we might want to add new fields to the spec.
val scatterPlot = VegaPlot.pwd("scatter.vl.json")
scatterPlot.plot(
_.data.values := data.asJson,
_.encoding.x.field := "Miles_per_Gallon",
_.encoding.y.field := "Horsepower",
_.encoding += json""" {"color": { "field": "Origin", "type": "nominal" }} """,
)
The final lines uses += to add a new field to the encoding object. Under the hood, this is circe's deepMerge function.
Accessing Array Elements
When working with Vega specs that have arrays of objects (like the data array in full Vega specs), you can access fields within array elements using either the .head property for the first element or index notation for any element.
For example, consider a Vega spec with this structure:
{
"data": [
{
"name": "table",
"values": [
{"category": "A", "amount": 28}
]
}
]
}
Accessing the First Element
You can update the values field in the first data element using .head:
val spec = VegaPlot.fromResource("seasonality.vg.json")
val data: Vector[(category: String, amount: Double)] = Vector(
(category = "A", amount = 100),
(category = "B", amount = 200),
(category = "C", amount = 300)
)
spec.plot(
_.data.head.values := data.asJson
)
You can also update multiple fields in the first array element:
spec.plot(
_.data.head.name := "updated_table",
_.data.head.values := data.asJson
)
Accessing Elements by Index (Homogeneous Arrays)
For accessing elements at specific positions (not just the first) in arrays where all elements have the same structure, use index notation with parentheses:
// Update the second element (index 1)
spec.plot(
_.data(1).name := "second_table",
_.data(1).values := newData.asJson
)
// Update the third element (index 2)
spec.plot(
_.data(2).values := otherData.asJson
)
The index accessor is equivalent to .head when using index 0:
// These are equivalent:
spec.plot(_.data.head.name := "table")
spec.plot(_.data(0).name := "table")
Important: The apply(index) accessor uses the first element's type for all indices. This means it assumes all array elements have the same structure. If you need to access elements with different structures, use tuple-style accessors instead.
Tuple-Style Accessors (Heterogeneous Arrays)
For arrays where elements have different structures, use tuple-style accessors (_0, _1, _2, etc.). Each accessor has the precise type of that specific element:
{
"layer": [
{ "data": { "values": [{"x": 1}] } },
{ "data": { "sequence": {"start": 0, "stop": 10, "step": 1} } }
]
}
In this example, layer[0] has data.values while layer[1] has data.sequence. To access these correctly:
spec.plot(
// _0 has type with data.values
_.layer._0.data.values := newValues.asJson,
// _1 has type with data.sequence
_.layer._1.data.sequence.start := 5
)
Using apply(1) here would fail to compile because it uses the first element's type (which has values, not sequence).
When to use which:
- Use
apply(index)(e.g.,_.data(2)) for homogeneous arrays where all elements share the same structure - Use tuple accessors (e.g.,
_.layer._1) for heterogeneous arrays where elements have different structures
Type Safety
Both .head and index access are type-safe and provide compile-time checking for nested fields within array elements. The element type is determined by the first element in the array at compile time for apply(index), while tuple-style accessors (_0, _1, etc.) provide precise per-element types.
If the array is empty or doesn't contain objects, neither .head nor index access will be available.
This feature is particularly useful when working with Vega specs (as opposed to Vega-Lite) where the data field is an array rather than a single object.
FromResource
Mill is my preferred build tool. Note that something like this;
lazy val plot = VegaPlot.fromResource("simple.vl.json")
May (confusingly) throw a compile error at you. Key point: VegaPlot is asking the compiler to analyze the JSON spec.
Mill seperates compile resources and run resources. From the compilers point of view, "simple.csv" is indeed not a resource by default in mill.
Now that we know this, it's easy enough to work around in a few ways. Here's one way that adds the runtime resources to the compilers resource path - thus ensuring that the CSV file is available to the compiler, at compile time.
trait ShareCompileResources extends ScalaModule {
override def compileResources = super.compileResources() ++ resources()
}