--- name: malloy-charts description: Read before choosing a chart or adding a chart annotation. Chart types, tag syntax and scale rules, KPI cards, dashboards, sparklines, and the renderer mistakes that fail silently. --- # Chart Selection for Malloy > Malloy uses Vega-Lite under the hood. `#` tags control visualization. Call `search_malloy_docs` with topic "rendering" for the full tag reference (or see https://docs.malloydata.dev/documentation/visualizations/overview). > **This file is about the Malloy renderer's `#` tags.** It applies when the view itself is rendered - a notebook, a dashboard, an explore result. It does **not** apply to an app that draws its own charts with a vendored chart library: there the tag vocabulary is irrelevant and the full form vocabulary is available, so the approximations below (a funnel as a bar chart, a treemap as a nested table) are the wrong advice. Design to whichever vocabulary the surface actually uses. > **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have. ## Decision Tree: Which Chart? | Data Shape | Default Choice | |-----------|---------------| | Aggregates only (no group_by) | `# big_value` | | 1 time column + 1 measure | `# line_chart` | | 1 category + 1 measure | `# bar_chart` | | 2 numeric columns | `# scatter_chart` | | Geographic (US states) + 1 measure | `# shape_map` | | Route data (lat/lon pairs) | `# segment_map` | | Multiple perspectives | `# dashboard` with `nest:` | | Nested query to pivot | `# pivot` | | Filtered aggregates side-by-side | `# flatten` | | Detailed rows | Default table (no annotation) | | Goal | Renderer | |------|---------| | Compare categories | `# bar_chart` (sort by value, limit ~15) | | Show composition | `# bar_chart.stack` | | Trend over time | `# line_chart` | | Highlight KPIs | `# big_value` with `# label` | | Correlation | `# scatter_chart` | | Compare dimensions | `# dashboard` (nest chart views) | | Before/after | `# transpose` or `# pivot` | | Multiple metrics per category | Default table, `# flatten`, or `y=['a','b']` | **Constraints:** - ONE aggregate per chart view (charts render only the first; use `y=['a','b']` for multi-measure) - No fixed scale on measure definitions: use `# currency` not `# currency=usd0m`. The same measure renders at many granularities, and `usd0m` turns $500 into `$0.0M`. Add a scale only in a view, after confirming the value range with a query. - One tag per line, each directly above the field it styles. Two `#` tags on one line do not work. - Alias joined fields in `group_by` before `order_by` - Define measures in source, not in views ## Chart Types ### `# bar_chart` **Data shape:** `group_by` = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series. ```malloy # bar_chart view: by_carrier is { group_by: carrier, aggregate: flight_count, order_by: flight_count desc, limit: 10 } # bar_chart.stack view: by_region is { group_by: category, region, aggregate: revenue } # bar_chart { y=['revenue','cost'] } view: rev_vs_cost is { group_by: category, aggregate: revenue, cost } ``` **Key properties:** `.stack`, `.size` (spark/xs/sm/md/lg/xl/2xl), `.x`, `.x.limit`, `.y` (supports `y=['a','b']`), `.series`, `.series.limit` (default 20), `.title`, `.subtitle`, `.x.independent`, `.y.independent` **Field role tags:** `# x`, `# y`, `# series` on individual fields to assign roles explicitly. ### `# line_chart` **Data shape:** `group_by` (temporal/numeric) = x-axis, `aggregate` = y-axis, optional 2nd `group_by` = series. ```malloy # line_chart view: trend is { group_by: order_month, aggregate: revenue, order_by: order_month } # line_chart { size=spark } view: mini_trend is { group_by: order_month, aggregate: revenue, order_by: order_month } ``` **Key properties:** `.zero_baseline`, `.interpolate` (e.g., `step`), `.size`, `.y` (supports `y=['a','b']`), `.series.limit` (default 12), `.title`, `.subtitle` ### `# scatter_chart` **Data shape:** Fields by position: x, y, color, size (bubble), shape. ```malloy # scatter_chart view: correlation is { group_by: customer_id, aggregate: avg_price, total_quantity } ``` ### `# shape_map` Choropleth. US states only. Fields: state name, value. ```malloy # shape_map view: by_state is { group_by: state, aggregate: revenue } ``` ### `# segment_map` Route map. US only. Fields: start_lat, start_lon, end_lat, end_lon, color. ## Layout Types ### `# big_value` KPI cards. Aggregates only, no `group_by`. ```malloy # big_value view: summary is { aggregate: # label="Revenue" # currency revenue # label="Orders" # number=auto order_count } ``` **Properties:** `.size`, `.sparkline=`, `.comparison_field`, `.comparison_label`, `.down_is_good` Put `# label` on every measure. Without it the card shows the raw field name, which is often unclear. ### `# dashboard` Card-based multi-tile layout. Apply to a view whose body is a nested query; the view's own fields lay out automatically: - `group_by` dimensions -> a row header (repeats once per row; omit for a single block) - `aggregate` measures -> KPI cards, one per measure - each `nest:` -> a tile, rendered by the tag above it (`# table` default, or `# bar_chart` / `# line_chart` / `# big_value`) **Two modes.** Flex (default): tiles flow and wrap; `# break` forces a new row. Columns: `# dashboard { columns=N }` lays tiles into N equal columns, `# colspan=n` widens a tile (the old `# span` is gone), `# break` starts a new row, overflow wraps. ```malloy // Flex: measures become KPI cards, the nest becomes a tile # dashboard view: overview is { group_by: category # currency aggregate: avg_retail is retail_price.avg() sum_retail is retail_price.sum() nest: # bar_chart by_brand is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 10 } } // Columns: # colspan widens tiles, # break ends a row # dashboard { columns=12 } view: layout is { group_by: category # currency aggregate: # colspan=4 avg_retail is retail_price.avg() # colspan=4 sum_retail is retail_price.sum() # colspan=4 max_retail is retail_price.max() nest: # break # colspan=6 # bar_chart # subtitle="Top brands" by_brand_chart is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 8 } # colspan=6 by_brand_table is { group_by: brand, aggregate: product_count is count(), limit: 8 } } ``` **Tags:** `# dashboard { columns=N }` (columns mode), `{ gap=PX }` (tile spacing, default 16; never a mode), `{ table.max_height=PX|none }` (cap table tiles). On a measure or nest: `# colspan=N` (columns mode only), `# break` (both modes), `# subtitle="..."` (tile), `# borderless` (drop card chrome), `# label="..."` (card title). For rich KPI cards (sparklines, comparison deltas, several metrics on one card) nest a `# big_value` view instead of relying on the dashboard's own measures: ```malloy # dashboard view: kpis is { group_by: category nest: # big_value revenue_card is { aggregate: # label="Revenue" # currency # big_value { sparkline=trend } total_revenue is retail_price.sum() # line_chart { size=spark y.independent=true } # hidden nest: trend is { group_by: bucket is floor(id / 100)::number, aggregate: total_revenue is retail_price.sum(), order_by: bucket, limit: 20 } } } ``` **Rules:** `# dashboard` needs a nested-query view (no effect on a scalar). `# colspan` works only in columns mode and is ignored (warns) in flex. `columns` is any positive integer; a `# colspan` over the column count clamps to a full row. Style tiles via the instance theme, or theme the views inside with `# theme.*` (see Theming below). ### `# pivot` Pivot nested results into columns. Max 30 pivot columns. ```malloy view: sales is { group_by: product, aggregate: total nest: # pivot by_quarter is { group_by: quarter, aggregate: revenue } } ``` ### `# transpose` Swap rows/columns. Good for period comparisons. ```malloy # transpose view: comparison is { aggregate: # label="This Month" current_revenue # label="Last Month" prior_revenue } ``` ### `# list` / `# list_detail` List renders as comma-separated values. List_detail shows `value (detail)` pairs. ### `# flatten` Collapse nested record into parent table as columns. Use for side-by-side filtered aggregates: ```malloy view: segments is { group_by: product, aggregate: total_revenue nest: # flatten enterprise is { where: segment = 'Enterprise', aggregate: # label="Enterprise" revenue } nest: # flatten smb is { where: segment = 'SMB', aggregate: # label="SMB" revenue } } ``` ### `# table` Default (implicit). Use explicitly for `.size=fill` property. ## Field Formatting Tags | Tag | Use For | Shorthand | |-----|---------|-----------| | `# number` | Numeric formatting | `=auto` (K/M/B), `=id` (no commas), `=1k`, `=1m` | | `# percent` | Percentages | (none needed) | | `# currency` | Money | `=usd2m` (USD, 2 decimals, millions); scale only in views | | `# duration` | Time durations | `=seconds`, `=minutes`, `=hours`, `=days` | | `# data_volume` | Storage sizes | `=bytes`, `=kb`, `=mb`, `=gb` | | `# link` | Hyperlinks | `.url_template="https://example.com/$$"` | | `# image` | Inline images | `.height=40px`, `.width=100px` | `# image` and `# link` are fine in a model file. A document held as text (one with a model-level `## artifact` tag, compiled at scope `append`) is refused if it writes either; define the field in the model file and check that edit at scope `file`. **Currency codes:** `usd` ($), `eur`, `gbp`. **Scale:** K/M/B/T/Q or `auto`. **Number suffix styles:** `word` ("42.5 million"), `letter` ("42.5M"), `scientific`. ## Utility Tags | Tag | Purpose | |-----|---------| | `# hidden` | Hide from output (still usable for sorting/references) | | `# label="..."` | Override display name | | `# description="..."` | Tooltip text | | `# tooltip` | Include nested view in chart tooltip | | `# break` | Force new dashboard row | | `# column { width=sm }` | Table column width | ## Model-Level Defaults ```malloy ## viz.line_chart.defaults.y.independent=true ## viz.bar_chart.defaults.stack ``` ## Theming Publisher styles charts and tables from one structured theme. The instance sets it (in `publisher.config.json`'s `theme` block or the **Settings, then Theme** editor); a model overrides it per result with `# theme.*` annotations, or model-wide with `## theme.*`. Per-chart annotations use the same nested `palette.*` / `font.*` vocabulary as the config, not flat key names, and they win over the instance theme for the keys they set. The forms: | Annotation | Controls | Modes | |-----------|----------|-------| | `# theme.palette.series` | Categorical series colors (array) | shared | | `# theme.palette.background.{light,dark}` | Chart canvas + table background | per-mode | | `# theme.palette.tableHeader.{light,dark}` | Table header text color | per-mode | | `# theme.palette.tableHeaderBackground.{light,dark}` | Table header row background | per-mode | | `# theme.palette.tableBody.{light,dark}` | Table body text color | per-mode | | `# theme.palette.tile.{light,dark}` | Dashboard tile background | per-mode | | `# theme.palette.tileTitle.{light,dark}` | Dashboard tile title color | per-mode | | `# theme.palette.mapColor.{light,dark}` | Choropleth gradient (`# shape_map` / `# segment_map`) | per-mode | | `# theme.palette.border.{light,dark}` | Table gridlines and row rules | per-mode | | `# theme.palette.cardBorder.{light,dark}` | Dashboard card edge and pinned table header rule | per-mode | | `# theme.palette.axis.{light,dark}` | Chart axis domain and tick lines | per-mode | | `# theme.palette.gridline.{light,dark}` | Chart gridlines | per-mode | | `# theme.palette.chartText.{light,dark}` | Chart axis, legend and title text | per-mode | | `# theme.palette.value.{light,dark}` | Big-value (KPI) number color | per-mode | | `# theme.font.family` | Font for all rendered text | shared | | `# theme.font.size` | Table font size (px) | shared | The thirteen per-mode `palette.*` color keys each take a `.light` and/or `.dark` variant so dark mode gets its own value. `palette.series`, `font.family`, and `font.size` are single values shared across modes (a `.light` or `.dark` on them does nothing). `palette.mapColor` recolors choropleths only; heatmaps keep their built-in scheme. No annotation sets the default light or dark mode or the user toggle: that lives in the instance theme. Environment-level theming is not applied yet. ```malloy // Model-wide defaults (## applies to every view in the model): ## theme.palette.series = ["#14b3cb", "#e47404", "#1474a4"] ## theme.font.family = "Inter, sans-serif" // Per-view override (# applies to this result only; beats the instance theme): # theme.palette.background.light = "#fafafa" # theme.palette.background.dark = "#111111" # theme.palette.tableHeader.dark = "#94a3b8" view: revenue_by_month is { group_by: month aggregate: revenue } ``` **Precedence**, highest to lowest, per key: `# theme.*` on the view, then `## theme.*` model default, then the instance theme, then Publisher's built-in defaults. A per-chart annotation overrides the instance for the keys it sets; unset keys fall through to the instance. (This is the reverse of a bare `@malloydata/render` embed, where the embedder wins: Publisher reads the annotation itself and layers it on top.) Quote values that contain spaces or a leading `#`. The light/dark default (`defaultMode`) and the toggle lock (`allowUserToggle`) are instance-only: set them in the config `theme` block or the editor, not as annotations. Annotation forms that look valid but do nothing, such as a flat `# theme.tableHeaderColor`, are dropped without an error. ## Advanced Patterns ### Sparklines in KPI Cards A sparkline needs two things: a `# hidden` nested view and a `.sparkline=` property naming it. If it does not show, check that `# hidden` is on the nested view and that its name matches `.sparkline=`. ```malloy # big_value { sparkline=trend } view: revenue_kpi is { aggregate: # label="Revenue" # currency revenue nest: # line_chart { size=spark } # hidden trend is { group_by: order_date, aggregate: revenue, order_by: order_date } } ``` ### KPIs with Comparison Deltas ```malloy # big_value { comparison_field=prior_month comparison_label="vs Last Month" } view: rev_delta is { aggregate: # label="Revenue" # currency revenue # hidden prior_month } ``` Use `down_is_good=true` for metrics where decrease is positive (churn, defects). ### Inline Mini-Charts in Table Rows ```malloy view: carriers is { group_by: carrier, aggregate: flight_count nest: # line_chart { size=spark } trend is { group_by: month, aggregate: flight_count, order_by: month } } ``` ### Multi-Measure Series ```malloy # bar_chart { y=['revenue','cost'] } view: rev_vs_cost is { group_by: quarter, aggregate: revenue, cost } ``` ### Hierarchical Drill-Down ```malloy # list_detail view: explorer is { group_by: region, aggregate: revenue nest: # bar_chart by_category is { group_by: category, aggregate: revenue, order_by: revenue desc, limit: 10 } } ``` ### Distribution (Histogram) There is no auto-binning function: `autobin(...)` does not exist and fails with `Unknown function 'autobin'`. Bin by arithmetic, choosing the width from the column's actual range: query `min`, `max` and a few percentiles first, and say in the view's doc where the width came from. A bin width nobody derived is a business decision in disguise. ```malloy # bar_chart view: price_dist is { group_by: bucket is floor(price / 20) * 20 // 20 is the bin width, from the observed range aggregate: order_count is count() order_by: bucket } ``` ## Patterns for Missing Chart Types | Desired | Malloy Approximation | |---------|---------------------| | Pie/donut | `# bar_chart` sorted by value | | Treemap | Nested table with `order_by: desc` | | Heatmap | `# pivot` with color values | | Stacked area | `# line_chart` with series (overlaid lines) | | Funnel | `# bar_chart` with ordered stages | | Gauge/bullet | `# big_value` with `.comparison_field` | ## Chart Annotations on Queries with `nest:` A top-level chart tag (e.g., `# bar_chart`) renders only the outer query; any `nest:` views are silently hidden from the rendering (still in raw data). To show nests, use `# dashboard` on the outer query with chart tags on each nest. Otherwise, drop the `nest:`. ## Common Mistakes | Mistake | Fix | |---------|-----| | Two aggregates in chart | ONE aggregate, or use `y=['a','b']` | | `# currency=usd0m` on measure | `# currency` (no scale) on defs; scale only in views | | Several tags on a `nest:` line | One lone tag on the `nest:` line works; with several, put each tag on its own line above the nested view | | Tags on same line | One tag per line | | Sparkline not showing | Add `# hidden` to nested view AND reference in `.sparkline=` | | Pivot > 30 columns | Filter/limit the nested group_by | NOTE: The term 'constructor' is a reserved term in Vega-Lite. If the word 'constructor' appears in the query, it will cause the rendering to fail. Never use it in a query and avoid using it as a dimension in a model. For more patterns, call `search_malloy_docs` with topics like "bar charts", "line charts", "dashboards", "histograms", "percent of total", "comparing timeframes", or "pivots". ## Further Reading - [Visualizations Overview](https://docs.malloydata.dev/documentation/visualizations/overview) - Official docs - [Bar Charts](https://docs.malloydata.dev/documentation/visualizations/bar_charts) - Stacked, grouped, series - [Bump Charts Blog](https://docs.malloydata.dev/blog/2023-10-26-malloy-bump-chart/) - Ranking over time - [Dataviz is Hierarchical](https://docs.malloydata.dev/blog/2024-02-29-hierarchical-viz/) - Nested data visualization philosophy