// Vega-Lite v5 chart-spec export for `DataFrame`. `format_vega_lite`
// produces a complete, standalone Vega-Lite v5 top-level specification —
// `$schema` + optional `title` + `mark` + `encoding` + an inline
// `data.values` array — as a JSON string. The output drops straight into
// the Vega editor () or any Vega-Lite
// runtime and renders without further wiring, so there is no private
// chart-data shape to learn: it is the standard declarative spec.
//
// This is its own package rather than a method on `DataFrame` or a corner
// of `io`: chart-spec export is serialisation to an external interchange
// format, not pure rendering (`frame` keeps `to_markdown` / `to_html`)
// and not tabular interchange (`io` owns the CSV / JSON / NDJSON readers
// and writers). It stays aligned with the JSON serialisers' cell
// conventions by driving the same records walk as `format_json` /
// `format_ndjson` — the `@io`-internal `df_to_json_records` seam — so a
// `data.values` cell follows exactly the same conventions as a
// JSON-records cell (nulls and non-finite floats become JSON `null`).
//
// Unlike `format_json` (which is **total**), `format_vega_lite`
// `raise`s: a `ChartSpec` names the `x` / `y` / `color` columns, and a
// name that is absent from the frame surfaces as `ColumnNotFound` rather
// than producing a spec that references a non-existent field.
///|
/// The mark type of a chart, mapped to a Vega-Lite top-level `mark`:
/// `Bar → "bar"`, `Line → "line"`, `Point → "point"`, `Area → "area"`.
/// `pub(all)` so callers can name the variants when building a `ChartSpec`
/// (though the `ChartSpec::bar` / `line` / `point` / `area` constructors
/// set the kind for you).
pub(all) enum ChartKind {
Bar
Line
Point
Area
} derive(Eq, Debug)
///|
pub extend ChartKind with Eq::{equal, not_equal}
///|
pub extend ChartKind with Debug::{to_repr}
///|
/// A Vega-Lite field `type`, used to override the dtype-based inference for the
/// `color` channel. `pub(all)` so callers can name the variants in a
/// constructor's `color_type`. `Quantitative` is a continuous measure (a color
/// gradient), `Nominal` an unordered category (distinct colors per value),
/// `Ordinal` an ordered category, and `Temporal` a time field.
pub(all) enum VegaType {
Quantitative
Nominal
Ordinal
Temporal
} derive(Eq, Debug)
///|
pub extend VegaType with Eq::{equal, not_equal}
///|
pub extend VegaType with Debug::{to_repr}
///|
/// A chart specification: the `mark` kind, the `x` / `y` encoding columns,
/// an optional `color` grouping column, and an optional `title`. Fields are
/// read-only outside the package — build a spec through one of the
/// mark-named constructors (`ChartSpec::bar(x, y)` / `line` / `point` /
/// `area`), naming `color` / `color_type` / `title` as needed:
///
/// `ChartSpec::bar("region", "revenue", title="Revenue by region")`
///
/// `x` / `y` / `color` are column names resolved against the frame at
/// `format_vega_lite` time (a missing name raises `ColumnNotFound`); each
/// column's dtype decides its Vega-Lite field `type` — the rule
/// `format_vega_lite`'s doc states (numeric → `quantitative`, `Date` →
/// `temporal`, otherwise → `nominal`) — unless `color_type` overrides the
/// `color` channel: e.g. a numeric grouping column (a cluster id) rendered as
/// `Nominal` distinct colors rather than a continuous gradient (only
/// `color` honours an override; `x` / `y` keep dtype inference).
pub struct ChartSpec {
kind : ChartKind
x : String
y : String
color : String?
title : String?
color_type : VegaType?
} derive(Eq, Debug)
///|
pub extend ChartSpec with Eq::{equal, not_equal}
///|
pub extend ChartSpec with Debug::{to_repr}
///|
/// A bar chart of `y` against `x` (Vega-Lite `mark: "bar"`).
///
/// The optional `color` / `color_type` / `title` parameters carry the
/// `ChartSpec` doc's colour-encoding and title contract (only `color`
/// honours a `color_type` override; `x` / `y` keep dtype inference).
pub fn ChartSpec::bar(
x : String,
y : String,
color? : String,
color_type? : VegaType,
title? : String,
) -> ChartSpec {
{ kind: Bar, x, y, color, title, color_type, }
}
///|
/// A line chart of `y` against `x` (Vega-Lite `mark: "line"`).
///
/// The optional `color` / `color_type` / `title` parameters carry the
/// `ChartSpec` doc's colour-encoding and title contract (only `color`
/// honours a `color_type` override; `x` / `y` keep dtype inference).
pub fn ChartSpec::line(
x : String,
y : String,
color? : String,
color_type? : VegaType,
title? : String,
) -> ChartSpec {
{ kind: Line, x, y, color, title, color_type, }
}
///|
/// A scatter (point) chart of `y` against `x` (Vega-Lite `mark: "point"`).
///
/// The optional `color` / `color_type` / `title` parameters carry the
/// `ChartSpec` doc's colour-encoding and title contract (only `color`
/// honours a `color_type` override; `x` / `y` keep dtype inference).
pub fn ChartSpec::point(
x : String,
y : String,
color? : String,
color_type? : VegaType,
title? : String,
) -> ChartSpec {
{ kind: Point, x, y, color, title, color_type, }
}
///|
/// An area chart of `y` against `x` (Vega-Lite `mark: "area"`).
///
/// The optional `color` / `color_type` / `title` parameters carry the
/// `ChartSpec` doc's colour-encoding and title contract (only `color`
/// honours a `color_type` override; `x` / `y` keep dtype inference).
pub fn ChartSpec::area(
x : String,
y : String,
color? : String,
color_type? : VegaType,
title? : String,
) -> ChartSpec {
{ kind: Area, x, y, color, title, color_type, }
}
///|
/// Render `df` and `spec` as a complete Vega-Lite v5 specification JSON
/// string: `$schema` (pinned to the Vega-Lite v5 schema URL) + optional
/// `title` + `mark` (from `spec.kind`) + `encoding` (`x` / `y` and, when
/// set, `color`, each `{field, type}`) + `data.values` (the frame inlined
/// as JSON records, sharing `format_json`'s cell mapping — null and
/// non-finite-float cells become JSON `null`). A frame with the encoded
/// columns but zero rows yields `"data":{"values":[]}`.
///
/// Each encoding channel's field `type` follows its column's dtype — the
/// single statement of the dtype→type rule: a numeric column (`Int` /
/// `Float`) is `"quantitative"`, a `Date` column is `"temporal"` (its ISO
/// cells — `data.values`' date units, via the JSON records' ISO mapping —
/// sort chronologically), and every other dtype is `"nominal"` — unless
/// the channel carries an explicit `color_type` override.
///
/// The spec's `x` / `y` / `color` columns are resolved left-to-right; the
/// first name absent from `df` raises `ColumnNotFound(name)` — this is the
/// one reason the function is not **total** (it accepts column names, and a
/// name can be wrong). The emitted text is always valid JSON (it is built
/// through `@json` and stringified), so it round-trips through any
/// standards-compliant JSON / Vega-Lite reader.
pub fn format_vega_lite(
df : @frame.DataFrame,
spec : ChartSpec,
) -> String raise @types.DataError {
// Resolve the referenced columns left-to-right — x, then y, then the
// optional color — so a missing column raises `ColumnNotFound` in that
// order. `get_column` doubles as the existence check and the dtype
// source for Vega-Lite's field-`type` inference.
let x_dtype = df.get_column(spec.x).dtype()
let y_dtype = df.get_column(spec.y).dtype()
let obj : Map[String, Json] = Map([])
obj["$schema"] = Json::string(
"https://vega.github.io/schema/vega-lite/v5.json",
)
if spec.title is Some(title) {
obj["title"] = Json::string(title)
}
// The Vega-Lite `mark` string for the chart kind.
obj["mark"] = Json::string(
match spec.kind {
Bar => "bar"
Line => "line"
Point => "point"
Area => "area"
},
)
let encoding : Map[String, Json] = Map([])
encoding["x"] = encoding_channel(spec.x, x_dtype, None)
encoding["y"] = encoding_channel(spec.y, y_dtype, None)
if spec.color is Some(color) {
encoding["color"] = encoding_channel(
color,
df.get_column(color).dtype(),
spec.color_type,
)
}
obj["encoding"] = Json::object(encoding)
let data : Map[String, Json] = Map([])
data["values"] = Json::array(@io.df_to_json_records(df))
obj["data"] = Json::object(data)
Json::object(obj).stringify()
}
///|
/// Write a Vega-Lite v5 spec for `df` / `spec` to `path`. Mirrors
/// `write_json`: a `ColumnNotFound` from `format_vega_lite` (a spec
/// column absent from `df`) propagates unchanged, a filesystem failure
/// surfaces as `raise IoError(message)`, and content holding an unpaired
/// UTF-16 surrogate is refused with `raise InvalidOperation`.
pub fn write_vega_lite(
path : String,
df : @frame.DataFrame,
spec : ChartSpec,
) -> Unit raise @types.DataError {
let content = format_vega_lite(df, spec)
@io.write_text(path, content)
}
// ── internals ──────────────────────────────────────────────────────────
///|
/// Build one Vega-Lite encoding-channel object `{"field": , "type":
/// }`. An explicit `type_override` wins; otherwise the field `type`
/// follows the column dtype: a numeric column (`Int` / `Float`) is
/// `"quantitative"`, a `Date` column is `"temporal"` (its ISO cells sort
/// chronologically), and every other dtype is `"nominal"`.
fn encoding_channel(
field : String,
dtype : @types.DataType,
type_override : VegaType?,
) -> Json {
let channel : Map[String, Json] = Map([])
channel["field"] = Json::string(escape_vega_field(field))
let vtype = match type_override {
// The Vega-Lite `type` string for a `VegaType`.
Some(t) =>
match t {
Quantitative => "quantitative"
Nominal => "nominal"
Ordinal => "ordinal"
Temporal => "temporal"
}
None =>
if dtype.is_numeric() {
"quantitative"
} else if dtype is @types.DataType::Date {
"temporal"
} else {
"nominal"
}
}
channel["type"] = Json::string(vtype)
Json::object(channel)
}
///|
/// Escape a column name for use as a Vega-Lite encoding `field`. Vega-Lite
/// reads an unescaped `.` as nested-object access and `[...]` as array
/// indexing, so a column literally named `price.usd` or `a[0]` would resolve
/// to a missing nested path and silently plot nothing. Quote characters are
/// worse: Vega compiles the field into a `datum[...]` accessor expression,
/// where a bare `'` or `"` terminates the string early and the generated
/// expression fails to parse — the exported spec throws at compile time
/// instead of rendering. Prefixing `\`, `.`, `[`, `]`, `'`, and `"` with a
/// backslash makes the name a literal field access (the JSON stringifier
/// then escapes the backslash itself). A single forward pass, total.
fn escape_vega_field(name : String) -> String {
let buf = StringBuilder()
for ch in name.iter() {
if ch == '\\' ||
ch == '.' ||
ch == '[' ||
ch == ']' ||
ch == '\'' ||
ch == '"' {
buf.write_char('\\')
buf.write_char(ch)
} else {
buf.write_char(ch)
}
}
buf.to_string()
}