9  Chart configs

library(rtemis.draw)

Attaching package: 'rtemis.draw'
The following object is masked from 'package:graphics':

    Axis

A chart config describes a chart as a document: which columns it binds, its semantics, its appearance. It holds no data and no rendering targets, it serializes to JSON, and it validates against a published schema. It is how a chart is defined in one place and drawn in another.

draw_scatter(x, y, ...)         ---------------------------> option -> widget
draw(ScatterConfig, data = df)  --compile()--> option -> widget

Two rules decide what belongs in a config:

  1. A config never carries data. It carries column names, plus an optional dat_path read at draw time.
  2. A config carries only what the author supplies. What the interface supplies — theme, width, height, element_id, filename — are arguments to draw(), not properties. That is what lets one document render correctly in an IDE pane and in a web app, adapting its presentation while keeping its meaning.

9.1 Building one

Every chart type has a setup_*Config() constructor. Arguments are column names where the chart binds data, and values everywhere else:

cfg <- setup_ScatterConfig(
  x = "bill_len",
  y = "flipper_len",
  group = "species",
  fit = "gam",
  title = "Penguin bill and flipper"
)
cfg
<ScatterConfig>

           type: <chr> scatter
       dat_path: <NUL> NULL
          title: <chr> Penguin bill and flipper
         origin: <chr> default, user, user, user, ...
         writer: <NUL> NULL
              x: <chr> bill_len
              y: <chr> flipper_len
           size: <NUL> NULL
          group: <chr> species
            fit: <chr> gam
             se: <lgc> TRUE
          n_fit: <int> 200
      fit_alpha: <nmr> 0.25
        palette: <NUL> NULL
         square: <lgc> FALSE
     equal_axes: <lgc> FALSE
            pad: <nmr> 0.04
           xlim: <NUL> NULL
           ylim: <NUL> NULL
           xlab: <NUL> NULL
           ylab: <NUL> NULL
     margin_top: <NUL> NULL
   margin_right: <NUL> NULL
  margin_bottom: <NUL> NULL
    margin_left: <NUL> NULL
 

Supply the data at draw time:

draw(cfg, data = penguins)

Or let the config name its own data, which is what makes it a standalone document. dat_path reads .csv (column names kept exactly as written) and .rds (for the charts that bind a matrix or an object rather than a table):

path <- file.path(tempdir(), "penguins.csv")
write.csv(penguins, path, row.names = FALSE)

draw(setup_ScatterConfig(x = "bill_len", y = "flipper_len", dat_path = path))

9.1.1 The chart types

chart_registry() is the single list of chart types, read both when a document is loaded and when the schemas are generated, so the two cannot disagree:

names(chart_registry())
 [1] "scatter"     "bar"         "density"     "histogram"   "line"       
 [6] "pie"         "boxplot"     "sankey"      "gantt"       "network"    
[11] "choropleth"  "heatmap"     "spectrogram" "a3"         

Each entry names the class that models the type and the setup_* that builds one:

str(chart_registry()$scatter)
List of 2
 $ cls  : <rtemis.draw::ScatterConfig/rtemis.draw::ChartConfig/S7_object> constructor
 $ setup: chr "setup_ScatterConfig"

9.2 resolve(): filling in what the data determines

resolve() derives the values the data implies — axis labels from the bound column names, limits from the values — and records where each one came from:

res <- resolve(cfg, data = penguins)
c(xlab = res@xlab, ylab = res@ylab)
         xlab          ylab 
   "bill_len" "flipper_len" 
res@xlim
[1] 31.0 60.7

It is idempotent, and it never overwrites a value the author set. It also derives what it can with no data at all, since column names alone determine the labels:

resolve(cfg)@xlab
[1] "bill_len"

9.2.1 Provenance

Every setup_*() records an origin for each property: "user" if the author named it in the call, "default" if the constructor filled it in. resolve() marks the ones it computes as "derived":

res@origin[c("x", "y", "fit", "xlab", "xlim", "pad")]
        x         y       fit      xlab      xlim       pad 
   "user"    "user"    "user" "derived" "derived" "default" 

This distinction is what lets a document move between interfaces with its intent intact. A margin the author set must be honored anywhere; a margin an IDE pane defaulted may be re-resolved for a large web canvas. Provenance is carried through a round trip, never recomputed — otherwise every default would harden into a choice the moment it was written.

9.3 compile(): config to render option

compile() turns a config into the backend option object that draw() renders. It materializes the data and resolves the config before dispatching, so no chart type can skip either step:

opt <- compile(cfg, data = penguins)
class(opt)
[1] "rtemis.draw::EChartsOption" "S7_object"                 

That option is exactly what the low-level API produces by hand, so you can inspect it, edit it, and draw it:

length(opt@series)
[1] 9
draw(opt)

draw(config, data = df) is compile() followed by draw(), plus any render hints the chart needs the browser to solve — a square plotting box, for instance, whose geometry depends on a container width that only the browser knows. Those hints are derived at draw time and never written into a document, because a box solved for an IDE pane is the wrong box for a large canvas.

9.4 Reading and writing JSON

write_chart_config() and read_chart_config() round-trip a config through JSON. By default the file is an input config: only the properties that are set, plus the origin map every setup_*() builds.

path <- file.path(tempdir(), "scatter.json")
write_chart_config(cfg, path)
cat(readLines(path), sep = "\n")
{
  "type": "scatter",
  "title": "Penguin bill and flipper",
  "origin": {
    "dat_path": "default",
    "title": "user",
    "x": "user",
    "y": "user",
    "size": "default",
    "group": "user",
    "fit": "user",
    "se": "default",
    "n_fit": "default",
    "fit_alpha": "default",
    "palette": "default",
    "square": "default",
    "equal_axes": "default",
    "pad": "default",
    "xlim": "default",
    "ylim": "default",
    "xlab": "default",
    "ylab": "default",
    "margin_top": "default",
    "margin_right": "default",
    "margin_bottom": "default",
    "margin_left": "default"
  },
  "x": "bill_len",
  "y": "flipper_len",
  "group": "species",
  "fit": "gam",
  "se": true,
  "n_fit": 200,
  "fit_alpha": 0.25,
  "square": false,
  "equal_axes": false,
  "pad": 0.040000000000000001
}
cfg2 <- read_chart_config(path)
cfg2@x
[1] "bill_len"

Reading is do.call(setup_*, x), so a document from any source arrives through the same seam a hand-written call goes through.

9.4.1 Output configs

complete = TRUE writes an output config: every property, unset ones as explicit nulls, with provenance attached and this package stamped as the writer. It is the form one interface hands to another, with nothing left to infer. Resolve first, so the values the data determines are written as the derived facts they are:

path_complete <- file.path(tempdir(), "scatter-complete.json")
write_chart_config(resolve(cfg, data = penguins), path_complete, complete = TRUE)
cat(head(readLines(path_complete), 30), sep = "\n")
{
  "type": "scatter",
  "dat_path": null,
  "title": "Penguin bill and flipper",
  "origin": {
    "dat_path": "default",
    "title": "user",
    "x": "user",
    "y": "user",
    "size": "default",
    "group": "user",
    "fit": "user",
    "se": "default",
    "n_fit": "default",
    "fit_alpha": "default",
    "palette": "default",
    "square": "default",
    "equal_axes": "default",
    "pad": "default",
    "xlim": "derived",
    "ylim": "derived",
    "xlab": "derived",
    "ylab": "derived",
    "margin_top": "default",
    "margin_right": "default",
    "margin_bottom": "default",
    "margin_left": "default"
  },
  "writer": {
    "name": "rtemis.draw",

Writing a complete document requires a full origin map, which only setup_*() builds — a config from a bare constructor cannot honestly claim to be complete.

9.4.2 As a list

chart_config_to_list() is the same conversion without the file. It shapes each value by its declared container, so a one-element array stays an array and a map stays an object:

str(chart_config_to_list(cfg))
List of 13
 $ type      : chr "scatter"
 $ title     : chr "Penguin bill and flipper"
 $ origin    :List of 22
  ..$ dat_path     : chr "default"
  ..$ title        : chr "user"
  ..$ x            : chr "user"
  ..$ y            : chr "user"
  ..$ size         : chr "default"
  ..$ group        : chr "user"
  ..$ fit          : chr "user"
  ..$ se           : chr "default"
  ..$ n_fit        : chr "default"
  ..$ fit_alpha    : chr "default"
  ..$ palette      : chr "default"
  ..$ square       : chr "default"
  ..$ equal_axes   : chr "default"
  ..$ pad          : chr "default"
  ..$ xlim         : chr "default"
  ..$ ylim         : chr "default"
  ..$ xlab         : chr "default"
  ..$ ylab         : chr "default"
  ..$ margin_top   : chr "default"
  ..$ margin_right : chr "default"
  ..$ margin_bottom: chr "default"
  ..$ margin_left  : chr "default"
 $ x         : chr "bill_len"
 $ y         : chr "flipper_len"
 $ group     : chr "species"
 $ fit       : chr "gam"
 $ se        : logi TRUE
 $ n_fit     : int 200
 $ fit_alpha : num 0.25
 $ square    : logi FALSE
 $ equal_axes: logi FALSE
 $ pad       : num 0.04

9.5 Schemas

The classes generate the JSON Schemas published at schema.rtemis.org. Each chart type publishes a schema.json (input config) and a record.json (output config), plus a dispatcher of each kind that selects a leaf on type:

sch <- chart_schema(
  ScatterConfig,
  id = "https://schema.rtemis.org/draw/scatter/schema.json",
  title = "Scatter chart config",
  description = "Input config for a scatter chart."
)
names(sch)
[1] "$schema"              "$id"                  "title"               
[4] "description"          "type"                 "properties"          
[7] "required"             "additionalProperties"
str(sch$properties$xlim)
List of 4
 $ type       : chr [1:2] "array" "null"
 $ items      :List of 1
  ..$ type: chr "number"
 $ minItems   : int 2
 $ description: chr "X axis limits. NULL derives them from the data."
str(sch$properties$fit)
List of 4
 $ type       : chr [1:2] "string" "null"
 $ enum       :List of 3
  ..$ : chr "glm"
  ..$ : chr "gam"
  ..$ : NULL
  ..- attr(*, "class")= chr "AsIs"
 $ minLength  : int 1
 $ description: chr "Fit to overlay. NULL draws no fit."

Two rules the emitter enforces:

  • No default is ever emitted. A default is what an interface chooses to fill in, not a fact about the document, and interfaces are expected to differ. Round-trip fidelity comes from writing resolved documents, not from sharing defaults.
  • An input config requires nothing beyond type, which carries the document’s shape. A config is partial by nature: the author sets a subset and the interface fills in the rest. The record.json variant requires every property, which is a claim about a written document rather than a constraint on what an author has to type.

Each leaf is self-contained — it declares its own type constant and closes with additionalProperties: false — so it validates standalone as well as through the dispatcher.

disp <- chart_dispatcher_schema(
  classes = list(ScatterConfig, BarConfig),
  id = "https://schema.rtemis.org/draw/chart.schema.json",
  leaf_ids = c(
    "https://schema.rtemis.org/draw/scatter/schema.json",
    "https://schema.rtemis.org/draw/bar/schema.json"
  ),
  title = "Chart config",
  description = "Any rtemis.draw chart config."
)
str(disp, max.level = 2)
List of 8
 $ $schema    : chr "https://json-schema.org/draft/2020-12/schema"
 $ $id        : chr "https://schema.rtemis.org/draw/chart.schema.json"
 $ title      : chr "Chart config"
 $ description: chr "Any rtemis.draw chart config."
 $ type       : chr "object"
 $ properties :List of 1
  ..$ type:List of 3
 $ required   : 'AsIs' chr "type"
 $ oneOf      :List of 2
  ..$ :List of 1
  ..$ :List of 1

write_chart_schema() writes one to disk. The package’s own generation script (data-raw/generate_schemas.R) walks chart_registry() and emits the full set; just schemas writes them into a schema-repo checkout and just schemas-check verifies they build.

© 2026 E.D. Gennatas