Grammar of Graphics in koryki
koryki turns a data query into a chart with a VISUALIZE clause — a declarative
Grammar of Graphics (GG) appended to a KQL query and compiled to a
Vega-Lite v6 specification. This document explains
the Grammar of Graphics and exactly how koryki implements it.
The VISUALIZE clause is inspired by ggsql; the
grammar, compiler, and semantics here are an independent Java/ANTLR implementation.
1. What is the Grammar of Graphics?
The Grammar of Graphics (Wilkinson, 2005; popularised by ggplot2 and Vega-Lite) says a chart is not a chart type ("bar chart", "pie chart") but a composition of independent parts. Change one part and you get a different chart from the same data:
| GG component | Question it answers | Example |
|---|---|---|
| Data | what rows? | the query result |
| Statistical transform | summarise the data first? | bin, count, quartiles, regression |
| Mark / geom | what shape draws a row? | point, line, bar, area |
| Aesthetic mapping | which column drives which visual channel? | revenue → y, category → colour |
| Scale | how do data values map to pixels/colours? | linear, log, a colour scheme |
| Coordinate system | what space? | cartesian, polar, a map projection |
| Facet | split into small multiples? | one panel per category |
| Guides / labels | axis titles, legend, formats | "Revenue (€)", %Y-%m |
The power is orthogonality: DRAW bar vs DRAW line swaps the mark without touching the
mapping; FACET category adds faceting without touching anything else.
2. How koryki adopts it
A KQL query already defines the data (FIND … FILTER … FETCH …). The VISUALIZE clause
adds the graphics layer on top of the same query. It is optional — not every result has a
meaningful chart.
FIND orders o, o order_details od, od products p, p categories c
FETCH c.category_name category, month(o.order_date) month,
sum(od.unit_price * od.quantity) revenue
VISUALIZE month AS x, revenue AS y, category AS color -- aesthetic mappings
DRAW line -- mark / geom
LABEL title => 'Revenue per category and month' -- guidesEach GG component maps to one keyword:
| GG component | KQL keyword | Compiles to (Vega-Lite) |
|---|---|---|
| aesthetic mapping | VISUALIZE col AS channel, per-layer MAPPING/REMAPPING |
encoding |
| mark / geom | DRAW <geom> (layers stack) |
mark / layer[] |
| annotation | PLACE <geom> SETTING … |
own-data annotation layer |
| statistical transform | DRAW histogram/boxplot/smooth/… or SETTING aggregate => … |
data computed in the DB |
| scale | SCALE [type] channel FROM … TO … VIA … RENAMING … |
encoding.<ch>.scale/axis/legend |
| coordinate system | PROJECT [aes] TO <coord> SETTING … |
axis swap / arc / projection |
| facet | FACET vars [BY vars] SETTING … |
facet + resolve |
| guides / labels | LABEL target => 'text' |
title / axis.title / legend.title |
Convention (shared with the rest of KQL): keywords are UPPERCASE, everything else —
channels, geoms, coordinate names, palettes, transforms — is lowercase ID, validated
semantically by the compiler rather than reserved in the grammar.
3. The compilation pipeline
KQL text
│ ANTLR grammar (kqlvisualize/src/main/antlr/ai/koryki/kql/KQLViz.g4 — visualizeClause)
▼
Parse tree
│ VisualizeMapper.map(...) (ai.koryki.visualize.VisualizeMapper)
▼
Visualize AST (ai.koryki.iql.query.viz.*)
│ Visualize · Layer · Mapping · ScaleSpec ·
│ FacetSpec · Projection · Label · Rename
▼
VegaLite emitter (ai.koryki.viz.VegaLite) ── statistical geoms ──► StatTransform
│ builds a Jackson ObjectNode tree (ai.koryki.viz.stat.*)
▼ computes bins / quartiles /
Vega-Lite v6 JSON regression / KDE **as SQL**,
runs it, inlines the result4. Aesthetic mappings and channels
VISUALIZE country AS x, revenue AS y, category AS color binds output columns (FETCH aliases)
to visual channels. Per-layer MAPPING adds channels for one DRAW; REMAPPING overrides
a channel (typically onto a statistical-transform output). A mapping to a column not in the
query's output is rejected with a clear error.
Channels (KQL name → Vega-Lite channel):
- positional:
x,y,x2,y2 - colour:
color(colour),fill - size/shape:
size,shape,opacity - text:
text(label),tooltip,detail - polar:
theta,radius - geographic:
longitude(lon),latitude(lat) - choropleth:
geometry - styling:
strokeWidth(linewidth),strokeDash(linetype),size(fontsize)
A literal instead of a column produces a constant channel value; * (wildcard) auto-maps every
output column whose name is a known channel.
5. Marks / geoms
DRAW <geom> chooses the mark; multiple DRAWs stack into a layer[]. KQL geom → Vega-Lite mark:
| geom | mark | geom | mark | |
|---|---|---|---|---|
point |
point | text |
text | |
line / path |
line | segment / rule / range |
rule | |
bar |
bar | tile |
rect | |
area / ribbon |
area | polygon |
line | |
spatial |
geoshape (choropleth) |
Statistical geoms (§7) are marks whose data is computed first: histogram, boxplot,
smooth, density, violin, heatmap.
PLACE <geom> is an annotation layer — every aesthetic is a literal from SETTING
(positional via datum, visual via value), drawn once over its own single-row data.
6. Scales, coordinates, facets, labels
SCALE [type] channel FROM domain TO range VIA transform SETTING … RENAMING …
- type:
CONTINUOUS·DISCRETE·BINNED·ORDINAL·IDENTITY FROM [..]→scale.domain;TO [..]/TO palette→scale.range/scale.schemeVIAtransform →scale.type:log/ln,log10,log2,sqrt,square,symlog(anddate/datetime/time→ temporal)SETTING breaks => …→ axis/legendvalues/tickCount;reverse => trueRENAMING 'a' => 'b'→ axis/legendlabelExpr
PROJECT [aes] TO <coord> SETTING … — the coordinate system:
cartesian(default). Listing aesthetics swaps axes (PROJECT y, x TO cartesian).polar→arcmark;y→theta,x→radius;SETTING inner => …for a donut.- map projections (~24 names:
mercator,orthographic,albers,equal_earth,natural, …) → a top-level Vega-Liteprojection;x/lon→longitude,y/lat→latitude;SETTING origin => (lon,lat)→rotate,parallel => (a,b)→parallels,sphere/graticule => true→ background layers. d3 projects client-side, so point maps work on every dialect with no DB spatial support. Arbitrary CRS (target/source) is rejected with a clear message.
FACET vars [BY vars] SETTING … — small multiples: one field → facet; BY → row/column
grid; SETTING ncol => n, free => [x,y] → resolve.scale independent.
LABEL target => 'text' — title sets the chart title; a channel name (x, y, color)
sets that guide's title.
7. Statistical layers — computed in the database
koryki does not ship a client-side stats engine. A statistical geom is compiled to SQL, executed on the target database, and the aggregated result is inlined into the spec. This keeps the browser payload small and pushes the heavy lifting to the DB.
ai.koryki.viz.stat.StatTransforms registers:
| geom | transform | computes (SQL) |
|---|---|---|
histogram |
HistogramStat |
binned counts (width_bucket / bin arithmetic) |
boxplot |
BoxplotStat |
quartiles per group (percentile_cont) → rule+bar+tick |
smooth |
SmoothStat |
OLS trend line (regr_slope/regr_intercept) |
density |
DensityStat |
kernel density estimate (KDE grid cross-join) |
violin |
ViolinStat |
KDE mirrored per group |
heatmap |
HeatmapStat |
2-D binned counts |
| (any geom) | AggregateStat |
opt-in via `SETTING aggregate => 'count' |
Engine.executeVegaLite runs the base query once (only when an ordinary layer needs the raw
rows), runs each statistical layer's SQL, and passes both to the emitter. Because these queries
compute in the DB, exact-value regression across parallel engines can be float-sensitive.
8. Geo-charting
koryki renders two kinds of maps from the same grammar, with no database spatial extension.
A point (symbol) map binds longitude/latitude columns to the lon/lat channels and picks a
cartographic coordinate system with PROJECT TO <projection> (equal_earth, orthographic, …);
koryki resolves the projection name to a Vega-Lite/d3 projection and leaves the spherical math to
the client. A choropleth binds a GeoJSON string column to the geometry channel under
DRAW spatial (a geoshape mark), and the region outlines are inlined straight into the compiled
Vega-Lite spec. Because the same KQL is transpiled to every SQL dialect koryki targets, geography
travels as backend-neutral data — numeric centroids and GeoJSON-as-text (see the northwind
countries lookup) — rather than native spatial types, so an identical map query runs on SQLite,
Oracle or Snowflake with no ST_* functions or PostGIS.
One qualifier: this dialect-portability holds for the map path, which carries geography as plain
numbers and text. The statistical geoms are a different matter — they generate their own SQL
(percentile_cont … WITHIN GROUP, regr_slope, range(0, n)) outside the dialect renderer, and
are currently exercised against DuckDB only.
9. Current limitations
Parsed by the grammar, rejected by the compiler with a named error rather than silently ignored:
SETTING position => 'jitter'— onlyidentity,stackanddodgeare implementedRENAMING '*' => …— the wildcard/template form; direct value renames workPLACE <geom> SETTING ch => (a, b)— array values (multi-annotation)PROJECT TO crs SETTING target/source => …— an arbitrary CRS; use a named projectionFACET a, b— Vega-Lite faces on one field per direction; writeFACET a BY bfor a gridSETTING bins/pointsabove 1000 — the KDE grid joins against every row, so an accidental large value is work the database does, not a bigger picture- projections that exist only in
d3-geo-projection(robinson,mollweide,winkel_tripel,sinusoidal,eckert4,igh,miller) are accepted but mapped onto the nearest projection in Vega-Lite's own set, since the spec'sProjectionTypeis a closed enum of 16 names
Two places where the compiler silently picks something workable instead:
SCALE … VIA logover data that reaches zero or below falls back tosymlog. A log scale is undefined there and Vega-Lite answers with a blank chart;symlogis linear around zero and logarithmic beyond, so the range compression survives. The base is dropped —symlogtakes a linear constant, not a base.FACET … SETTING ncolapplies to the single-field form only. In aBYgrid the layout is already spelled out and Vega-Lite ignores the property, so it is not emitted.
There is no chart-level SETTING: width, height and theming are not expressible in the
grammar. Maps and polar charts therefore get a fixed size from the compiler (640×400 and 360×360),
because Vega-Lite's 200×200 default makes a world map unreadable and leaves a pie chart
non-circular. Cartesian charts keep Vega-Lite's own sizing — discrete axes grow by step with the
number of categories, which beats any fixed number chosen here.
ggsql vs koryki. Posit's ggsql shows how the Grammar of
Graphics can be expressed in SQL syntax; koryki carries that idea in a different direction — an
independent Java/ANTLR implementation whose VISUALIZE grammar sits on top of KQL's schema-aware
semantic layer and compiles to Vega-Lite across every supported dialect. For maps that difference is
deliberate: koryki favours dialect-portability — client-side projection over engine-specific
geospatial operators — so one chart definition works the same on every backend.