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 / Decimal) is "quantitative", a Date / Timestamp column
is "temporal" (its ISO cells — data.values' date units, via the JSON
records' ISO mapping — sort chronologically, and a temporal axis orders
by time rather than by first appearance), 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.