---
title: "Analytics Display Types"
description: "Every Analytics display type and the settings a panel of that type takes, generated from the same definitions Cirra's Analytics assistant builds with."
---

A panel's `displayType` decides how it draws its rows, and each type reads its settings from one block. Send a block with [Update Analytics Panel](/docs/api/rest/actions/update-analytics-panel) or in a panel of [Set Analytics Page Definition](/docs/api/rest/actions/set-analytics-page-definition). A block you send is merged over the stored one: send only the settings you change, and `null` to clear one. A block a type does not draw is refused. Aggregate in the panel's SQL first, then map its columns here.

## `format`

Sent as the panel's own `format` field.

What KIND of value each column holds, keyed by column name — \{"revenue": "currency", "day": "date", "ctr": "percent"\}. THE answer to "show this as money / as a percentage / with a $", on ANY display type: the chart's value axis, its hover card, the table cell and the tile all read this one declaration, so a column typed once reads the same everywhere it is drawn. Allowed types: string, number, percent, ratio, currency, duration, rank, boolean, date, object, list. Emit RAW numbers from SQL and declare the type here — never format a value into a string yourself (no hand-written currency symbols, percent signs, or thousands separators). A rate is "percent" when it arrives as 12.4 and "ratio" when it arrives as 0.124; both print 12.4%. Merged over what is stored, so you can type one column without resending the others; send "" for a column to take its type back off. A key naming a column the panel does not draw is inert.

A map keyed by column name. Each value: string. One of: string, number, percent, ratio, currency, duration, rank, boolean, date, object, list.

## `links`

Sent as the panel's own `links` field.

Table only: which columns' cells open a URL, keyed by the displayed column — \{"owner_name": \{"urlKey": "profile_url"\}\}. The cell keeps its normal value and type; it acts as a link on hover and click. THE answer to "make these clickable / link each row to its record" — a column of raw URLs is plain text until declared here too (\{"website": \{"urlKey": "website"\}\}). Only http(s) values render as links; a row whose urlKey value is empty or not a URL renders that cell as plain text. Merged over what is stored, so you can link one column without resending the others; send null for a column to take its link back off.

Also carried by: `table`.

A map keyed by column name. Each value: object. Takes: urlKey (string), target (string).

## `style`

Sent as the panel's own `style` field.

How THIS panel looks on top of the page theme. Only for a colour the user pins to a specific series or category on this one chart ("make Gryffindor red", "clicks in blue"); a look for the whole page or every page is a theme or page style, never this.

Also carried by: `bar`, `line`, `combo`, `pareto`, `pie`, `treemap`, `funnel`, `bump`, `bubble`, `quadrant`, `statusgrid`, `sankey`, `chord`, `statetimeline`, `map`.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `seriesColors` | object | no | A light/dark pair per NAME the panel colours by, exactly as the value appears in the rows: the slice label (pie, treemap), the series column key (bar, line, combo), the step (funnel), the status or state, the bubble category, the flow node, the marker group. \{"Gryffindor": \{"light": "#7f0909", "dark": "#c41e1e"\}\}. Merged over what is stored, so colour one name without resending the others; a name set to null goes back to the theme colour; the whole map set to null clears every pinned colour. A name the panel is not drawing is inert. |

## `annotations`

Sent as `settings.annotations` on the panel.

Event lines (bar, line and combo charts): dashed vertical lines marking the categories where something happened — a launch, a price change, an outage — so the reader sees the cause beside the effect. The events are a COLUMN on the same rows the panel already plots, never a list of dates here: a row whose column carries text gets a line labelled with it, every other row gets nothing. Bring the column in with the panel's own SQL — LEFT JOIN the table the events live in, or `CASE WHEN day = '2026-07-22' THEN 'Pricing change' END AS event` for a date the user named — which is also what keeps the lines correct after a refresh. An event on a date the chart does not plot draws no line, so emit that row from SQL if the story needs it.

Also carried by: `bar`, `line`, `combo`.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `labelKey` | string | no | Column naming what happened, read from the rows the panel plots (e.g. "event"). Rows where it is empty or null are the normal case and draw nothing. The text is shown when the reader hovers that category, so write it as a short human phrase ("Pricing change"), not an id. Pass "" to take the lines off the panel. |

## `tooltip`

Sent as `settings.tooltip` on the panel.

Extra lines on the hover card (bar, line and combo charts), under the category label and the plotted values it already shows. THE answer to "when I hover I want to see which ones these are" — a hover card cannot be clicked into, so reach for this rather than a drill-down, which needs a click and opens a whole view. The values are COLUMNS on the same rows the panel plots, so put the detail in the panel's own SQL: aggregate to one row per category and carry a short list beside the count (`count(*) AS sqls, list(owner_name)[1:5] AS top_owners`), capped in the SQL because a row holding thousands of names pays for them on every load. Five items print and the rest are counted.

Also carried by: `bar`, `line`, `combo`.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `fields` | array of object | no | The columns whose values the hover card also prints, in order. Full replacement; an empty list takes the extra lines off. Each item takes: key (string), type (string). |

## `chart`

Sent as `settings.chart` on the panel.

Chart mapping (required when displayType is "bar", "line", "combo", or "pareto"): each row becomes one bar / point. Aggregate in the source's query first (one row per category or date with its count, ORDER BY chronological for line charts), then map the columns here — a chart is the clearest case for doing the work in SQL.

Drawn by: `bar`: bar chart; `line`: line chart; `combo`: a total and a rate on one panel — bars for one, a line for the other, each read against its own scale; `pareto`: how few categories carry most of the total — bars ranked largest first under a cumulative share line.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `xKey` | string | yes | Column used as the category label (x axis). Line charts usually use a date column. |
| `series` | array of object | yes | The plotted series, in order — one per numeric column (bar height / line value). Several render as their own color-coded series. Pareto charts take exactly one: it is the amount they rank and add up, and the cumulative line is computed from it. Each item takes: key (string), show (boolean), mark (string). |
| `orientation` | string | no | Bar charts only: "vertical" (default) for upright bars, "horizontal" for bars running left-to-right (better for long category labels or many categories). Ignored for line and combo charts. One of: vertical, horizontal. |
| `barMode` | string | no | Bar charts with multiple yKeys only. "grouped" (default): bars side by side — use to COMPARE series against each other. "stacked": one bar per category with each series as a segment — use when the series are PARTS OF A WHOLE (e.g. expense categories summing to a monthly total). Ignored for line and combo charts, whose bars are always side by side. One of: grouped, stacked. |
| `curve` | string | no | Line, combo, and pareto charts — how hard the corners are between points. "straight": exact straight segments, best when every point matters (counts, discrete events, few points). "smooth" (default): gently rounded, never bows past a real value. "natural": the roundest, for reading overall shape over many points — it can bow past a real value between points, so avoid it when precision matters. Ignored for bar charts. One of: straight, smooth, natural. |
| `showLegend` | boolean or string | no | Whether the legend naming the series appears under the chart. Omit it: a chart with more than one series shows it, two value axes included, and it is the only place the series are named. Set false only when the user asks for the legend gone; "" puts it back on Auto. |
| `showPoints` | boolean | no | Line, combo, and pareto charts: mark every plotted point with a dot on its line. Off when omitted. Set true when individual readings matter — few points, or the user asks to see the points. Ignored for bar charts. |
| `showValues` | boolean | no | Print each plotted value on the chart — above every point or bar, with a stacked bar printing one total per column. Off when omitted; set true only when the user asks for the numbers on the chart. Values print through each column's declared format, and labels thin to every nth category when they are too dense to all fit. |
| `singleAxis` | boolean | no | Omit it. The chart decides its value axes from the data: one shared axis while every series reads on it, a right axis once two series need different scales. Set true ONLY when the user explicitly asks for one axis or says they do not want the second axis; "on the same chart" or "together" is not that ask. A series with a far smaller range then draws flat, which is the trade they are choosing. Ignored for pareto charts, whose share line always has its own axis. |

## `bump`

Sent as `settings.bump` on the panel.

Bump mapping (displayType "bump"): ONE ROW IS ONE SERIES IN ONE PERIOD, and what is plotted is the series' POSITION in that period, not its value, so every crossing of two lines is an overtake. Send EITHER valueKey, and the panel works the positions out per period, OR rankKey when the source already carries them — never both. Prefer valueKey: a GROUP BY already returns the value, while a rank has to be built with a window function per period, and the panel owns the tie rule either way. THE ROW ORDER IS THE PERIOD ORDER — the panel never reorders, so the query must return the periods oldest first. Positions are worked out among the series this panel DRAWS, so filter to the contenders in SQL rather than sending the whole catalog. A series with no row in a period has no mark there and its line breaks across the gap. Capped at 8 series and 12 periods, with anything not drawn counted below the plot. Read immutable/analytics/panel-types/bump.md before configuring one.

Drawn by: `bump`: who moved up and who moved down in a ranking, period by period — one row per series per period, up to 8 series across 12 periods.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `periodKey` | string | yes | Column naming the period the position was held in (a month, a week, a quarter). Its distinct values become the columns of the plot, in the order they first appear. Dot paths work. Keep the labels short: each one sits above a narrow column. |
| `seriesKey` | string | yes | Column naming the thing being ranked. Its distinct values become the lines, and each one takes a colour from the shared palette in the order it first appears. Dot paths work. |
| `valueKey` | string | no | Column holding the number the position is worked out from, for the period on the same row. Dot paths work. This is the one to send in almost every case: a GROUP BY already produces it. An empty, blank, or non-numeric value is counted below the plot rather than read as a zero. Send this OR rankKey, never both. |
| `rankKey` | string | no | Column holding the position itself, when the source publishes one the panel could not work out (a chart position, a league table, a leaderboard endpoint). Whole numbers from 1 to 8; anything else, including a repeat of a position already taken in that period, is counted below the plot rather than drawn. Dot paths work. Send this OR valueKey, never both. |
| `order` | string | no | Which end of the value takes first place, when the panel works the positions out. "highest" (default) for revenue, uses, signups. "lowest" for finishing times, response times, cost per lead. Ignored when rankKey is set, since a rank column already says which end is first. One of: highest, lowest. |

## `scatter`

Sent as `settings.scatter` on the panel.

Scatter mapping (displayType "scatter", "bubble" or "quadrant"): ONE ROW IS ONE DOT, placed at its pair of NUMERIC values. Do not aggregate: the rows are the individual records being compared (one deal, one customer, one ticket), so the query filters and LIMITs rather than grouping. Unlike every other type, the row count is bounded by the records, not by a number of categories: 500 dots are drawn and anything past that is counted below the plot, so narrow the ask in SQL. "bubble" is the same mapping plus a THIRD number drawn as the mark's AREA (sizeKey, required for that name) and an optional category as its colour (colorKey), and it draws 200 marks rather than 500 because each one is far larger. A "quadrant" additionally REQUIRES xThresholdKey and yThresholdKey, names each record on hover, and draws only 40. Read immutable/analytics/panel-types/&lt;scatter|bubble|quadrant>.md before configuring one.

Drawn by: `scatter`: how two numbers relate — one row per dot, both axes numeric, capped at 500; `bubble`: how two numbers relate and how big each record is — one row per bubble, a third number as its area, up to 200; `quadrant`: which records to act on first: the same two numbers cut by a threshold on each axis into four named zones, record names on hover, capped at 40.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `xKey` | string | yes | Column holding the numeric value on the horizontal axis, usually the suspected cause or input (e.g. "days_to_close"). Dot paths work. |
| `yKey` | string | yes | Column holding the numeric value on the vertical axis, usually the outcome (e.g. "amount"). Dot paths work. |
| `labelKey` | string | yes | Column naming the record each dot stands for, shown when a dot is hovered (e.g. "deal_name"). Dot paths work. Required: an outlier the reader cannot identify is a dot they can do nothing with. |
| `sizeKey` | string | no | Bubble charts only, and REQUIRED there: column holding the third number, drawn as the bubble AREA rather than its radius (e.g. "seats"). It answers "and how big is each one?", so it is a magnitude the reader can add up — a headcount, an amount, a volume. A rate, a percentage, or a score is the wrong column, because half the area does not mean half a percentage. Rows whose value is missing, non-numeric, or NEGATIVE are counted below the plot rather than drawn; zero draws at the minimum size. Omit it for "scatter". Dot paths work. |
| `colorKey` | string | no | Bubble and quadrant charts, optional: column whose distinct values colour the marks and fill the legend (a segment, a team, an owner). Every colour is on screen at once here, so keep the set to a handful and GROUP the long tail in SQL: the first 8 categories to appear get a palette colour, and every later one is folded into one grey "Other" swatch. Omit it when the panel follows one population, which leaves every mark the same colour. On a quadrant, never set it to something the zones already say — the zone is the position, so colouring by it says the same thing twice. Omit it for "scatter". Dot paths work. |
| `showLegend` | boolean | no | Bubble and quadrant charts: whether the legend naming each colorKey category appears under the plot. Defaults true. Set false when the user asks for the legend gone — the hover card still names every mark. Ignored for "scatter", which has no colours to name. |
| `xThresholdKey` | string | no | QUADRANT ONLY, required there: column holding where the vertical divider stands. Always a column, never a number here — emit an absolute rule as `120 AS demand_line`, or the data's own centre as `AVG(demand) OVER () AS demand_line` or `MEDIAN(demand) OVER () AS demand_line`, so the rule recooks with the rows instead of freezing in the panel. The first row carrying a number in it sets the divider for the whole plot. |
| `yThresholdKey` | string | no | QUADRANT ONLY, required there: column holding where the horizontal divider stands, on the same terms as xThresholdKey. |
| `zoneTopRight` | string | no | QUADRANT ONLY: what to call the zone above the horizontal divider and right of the vertical one (e.g. "Keep sharp"). A zone left unnamed still draws; it just carries no name. |
| `zoneTopLeft` | string | no | QUADRANT ONLY: what to call the zone above the horizontal divider and left of the vertical one. |
| `zoneBottomRight` | string | no | QUADRANT ONLY: what to call the zone below the horizontal divider and right of the vertical one — usually the act-first corner (e.g. "Fix first"). |
| `zoneBottomLeft` | string | no | QUADRANT ONLY: what to call the zone below the horizontal divider and left of the vertical one. |

## `pie`

Sent as `settings.pie` on the panel.

Pie mapping (displayType "pie"): ONE ROW IS ONE SLICE of a meaningful whole. Aggregate, order largest-first, and LIMIT 8 in the source SQL; the renderer also enforces eight and reports anything it cannot draw. Read immutable/analytics/panel-types/pie.md before configuring one.

Drawn by: `pie`: parts of one whole — one row per positive slice, capped at eight.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `labelKey` | string | yes | Column naming the slice. Dot paths work. |
| `valueKey` | string | yes | Column holding the positive numeric slice value. Dot paths work. |
| `showLegend` | boolean | no | Whether the panel may show its slice legend. Defaults true. A narrow panel hides the legend when it cannot fit, while the tooltip remains available. Set false whenever the reader wants the legend gone or wants less text on the panel: the hover card still names every slice, so hiding it loses nothing they cannot get back. |
| `compactLegend` | boolean | no | Whether to place slice labels in a compact row below the pie without values. A short panel moves them beside the pie when that keeps the donut readable. Defaults false. When true, legendValue has no visual effect. |
| `legendValue` | string | no | What appears beside each legend label: the formatted source "value" (default), its "percentage" of the drawn total, or "both". The tooltip always shows the exact value and percentage. One of: percentage, value, both. |

## `treemap`

Sent as `settings.treemap` on the panel.

Treemap mapping (displayType "treemap"): ONE ROW IS ONE TILE of a meaningful whole. Aggregate, order largest-first, and LIMIT 50 in the source SQL; the renderer also enforces 50 and reports anything it cannot draw. Read immutable/analytics/panel-types/treemap.md before configuring one.

Drawn by: `treemap`: relative area across a larger parts-of-whole category set — one row per positive tile, capped at 50.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `labelKey` | string | yes | Column naming the tile. Dot paths work. |
| `valueKey` | string | yes | Column holding the positive numeric tile value. Dot paths work. |

## `cellGrid`

Sent as `settings.cellGrid` on the panel.

Cell grid mapping (displayType "heatmap", "pivot" or "cohort"): ONE ROW IS ONE CELL, the crossing of a row label and a column label. THE ROW ORDER IS THE GRID ORDER, in both directions: rows and columns appear in the order they are first seen, and the panel never sorts. Aggregate to one value per pair and ORDER BY both dimensions in the source SQL, capped at 30 rows by 12 columns; the renderer enforces the same and reports anything it cannot draw. The three names share this block: "heatmap" shades each cell from the smallest value in the grid to the largest, "pivot" prints the number instead, and "cohort" reads the rows as cohorts and the columns as periods since each cohort started, dividing every cell by its own row's first period to print and shade a retention share. Read immutable/analytics/panel-types/&lt;heatmap|pivot-table|cohort>.md before configuring one.

Drawn by: `heatmap`: in which cross of two dimensions a number concentrates: one row per cell, shaded by value, capped at 30 rows by 12 columns; `pivot`: numbers broken down by two dimensions at once, as a cross-tab: the same grid with the values printed and no shading, capped at 30 rows by 12 columns; `cohort`: how much of each starting group is still there as time goes on: one row per cohort, one column per period since that cohort started, each cell the share of the cohort left, capped at 30 rows by 12 columns.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `rowKey` | string | yes | Column whose values become the grid rows, one per distinct value, in first-seen order. Dot paths work. For "cohort" this is the cohort label, the period a group first appeared, oldest first. |
| `columnKey` | string | yes | Column whose values become the grid columns, one per distinct value, in first-seen order. Dot paths work. Put the dimension with fewer distinct values here: 12 columns fit, 30 rows fit. For "cohort" this is the period since that cohort started, ordered from its first, and the same label must mean the same offset for every cohort. |
| `valueKey` | string | yes | Column holding the number in the cell. Dot paths work. Zero and negative numbers are real data and are drawn; an empty or non-numeric cell is counted as not shown and left blank. A second row landing on a pair that already has a cell keeps the first and is counted too, so aggregate in SQL. For "cohort" this is a count of the cohort members still counted in that period, and the panel divides it by the row's first period rather than printing it, so do NOT pre-compute a percentage here. |

## `statusGrid`

Sent as `settings.statusGrid` on the panel.

Status grid mapping (displayType "statusgrid"): ONE ROW IS ONE CELL, the crossing of a row label and a column label, and the cell carries a CATEGORY rather than a number. THE ROW ORDER IS THE GRID ORDER, in both directions: rows and columns appear in the order they are first seen, and the panel never sorts. Return one status per pair and ORDER BY both dimensions in the source SQL, capped at 30 rows by 12 columns; the renderer enforces the same and reports anything it cannot draw. Distinct statuses take the shared series palette in order of first appearance and a legend above the grid names every one of them, so keep the set small: past eight statuses the palette repeats a colour. Absence is not a category: a pair no row supplied draws as an empty cell, so a meaningful gap like "no invoice" has to be a status the query returns. Read immutable/analytics/panel-types/statusgrid.md before configuring one.

Drawn by: `statusgrid`: what state each record was in, period by period: one row per cell, coloured by category with a legend naming each one, capped at 30 rows by 12 columns.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `rowKey` | string | yes | Column whose values become the grid rows, one per distinct value, in first-seen order. This is the record the reader is following. Dot paths work. |
| `columnKey` | string | yes | Column whose values become the grid columns, one per distinct value, in first-seen order. This is the period. Dot paths work. Put the dimension with fewer distinct values here: 12 columns fit, 30 rows fit. |
| `statusKey` | string | yes | Column naming the state the record was in for that period. Its distinct values become the colours and the legend, so keep them to a small closed set and group the long tail in SQL. Dot paths work. A blank status draws as "(blank)" with a colour and a legend entry of its own rather than being dropped. A second row landing on a pair that already has a cell keeps the first and is counted below the grid, so decide the one status per pair in SQL. |
| `showLegend` | boolean | no | Whether the legend naming each status appears under the grid. Defaults true. Set false only when the user asks for the legend gone: a cell is nothing but its colour, so with the legend hidden the grid can only be read by hovering. |

## `activity`

Sent as `settings.activity` on the panel.

Activity calendar mapping (displayType "activity"): ONE ROW IS ONE DAY. THE ROW ORDER MEANS NOTHING HERE, unlike every other grid type: a square is placed by its date, in a column per week and a row per weekday, so the query owns which days exist rather than where they sit. Aggregate to one value per day in SQL, with no gap-filling: a day the query never returns is drawn as an empty square, which is what "nothing happened" looks like. Capped at the most recent 53 weeks; the renderer enforces that and reports every day it could not draw. Read immutable/analytics/panel-types/activity.md before configuring one.

Drawn by: `activity`: whether a daily number held steady or came in bursts: one small square per day on a calendar, shaded by that day's value, the most recent 53 weeks.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `dateKey` | string | yes | Column holding the day the square sits on. Dot paths work. A date, or a string a date can be read from ("2026-03-14", "2026-03-14T09:00:00Z"); a bare epoch number is ambiguous between seconds and milliseconds and is counted as undrawable rather than guessed. A value carrying a time is read as the day it names, not converted to another timezone. |
| `valueKey` | string | yes | Column holding that day's number. Dot paths work. Zero and negative numbers are real data and are drawn; an empty or non-numeric cell is counted as not shown and left blank. A second row landing on a day that already has a square keeps the first and is counted too, so aggregate in SQL. |

## `funnel`

Sent as `settings.funnel` on the panel.

Funnel mapping (displayType "funnel"): ONE ROW IS ONE STEP of a sequence, and THE ROW ORDER IS THE STEP ORDER — the panel never reorders, so the query must return the steps top to bottom. The value is CUMULATIVE: how many reached at least that step, not how many sit in that stage now. A stage-by-stage snapshot is a bar chart, not a funnel; when the source has no stage history, build the running total in SQL. Capped at 12 steps, with anything not drawn counted below the bars. Read immutable/analytics/panel-types/funnel.md before configuring one.

Drawn by: `funnel`: how many survive each step of a sequence — one row per step, in step order, capped at 12.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `categoryKey` | string | yes | Column naming the step. Dot paths work. |
| `valueKey` | string | yes | Column holding the number that reached that step. Dot paths work. Zero is drawn (the step where the pipeline ran dry); an empty, non-numeric, or negative count is counted as not shown. |
| `orientation` | string | no | How the funnel flows. "horizontal" (default) runs left to right with stage cards below. "vertical" runs top to bottom with stage cards on the right. One of: horizontal, vertical. |
| `percentBase` | string | no | Which percentage each stage shows. "first" (default) is the share of the first step, for "what fraction of everyone who entered got this far". "previous" is the share of the step above, for "where are we losing people" — a low number points straight at the step that leaks. One of: first, previous. |

## `waterfall`

Sent as `settings.waterfall` on the panel.

Waterfall mapping (displayType "waterfall"): ONE ROW IS ONE STEP from one total to another, and THE ROW ORDER IS THE STEP ORDER — the panel never reorders. The amount is a CHANGE, signed: positive adds to the running total, negative takes away. A list of balances is a bar chart, not a waterfall; when the source only reports balances, difference them in SQL. Flag the opening and closing rows through totalKey so they sit on zero instead of floating on the running total. Capped at 12 steps, with anything not drawn counted below the bars. Read immutable/analytics/panel-types/waterfall.md before configuring one.

Drawn by: `waterfall`: how one total becomes another — one row per gain, loss, or total, in order, capped at 12.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `categoryKey` | string | yes | Column naming the step. Dot paths work. |
| `valueKey` | string | yes | Column holding the signed amount this step moved the running total by, or the level itself on a row flagged as a total. Dot paths work. Zero is drawn (the step that changed nothing); an empty or non-numeric amount is counted as not shown. |
| `totalKey` | string | no | Optional column flagging the rows that RESTATE the total rather than change it — the opening balance, the closing balance, any subtotal between them. Such a row is drawn from zero to its own value, and the running total continues from there. Dot paths work. The flag counts as set only for true or 1 (a boolean, the number, or either as a string), so emit it as `true AS is_total`; "yes" and "Y" are read as ordinary steps. Omit the column entirely and every row is a change, so the panel has no opening or closing bar. |

## `sankey`

Sent as `settings.sankey` on the panel.

Sankey mapping (displayType "sankey"): ONE ROW IS ONE FLOW between two labelled nodes, and its value is ALREADY AGGREGATED, so GROUP BY the pair in the source SQL and never send raw records. The same label is the same node wherever it appears, in either column, which is what chains A to B to C into more than two layers. A flow that runs back into somewhere the volume has already been has no layer to sit in, so it is counted below the diagram instead of drawn. Capped at 24 flows, with anything not drawn counted below the diagram. Read immutable/analytics/panel-types/sankey.md before configuring one.

Drawn by: `sankey`: where a volume came from and where it ended up, one row per flow with its own already-aggregated total, capped at 24.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `sourceKey` | string | yes | Column naming where the flow comes from. Dot paths work. |
| `targetKey` | string | yes | Column naming where the flow ends up. Dot paths work. |
| `valueKey` | string | yes | Column holding the positive numeric amount that moved along this flow. Dot paths work. An empty, non-numeric, zero, or negative amount has no ribbon to draw and is counted as not shown. |
| `showLegend` | boolean | no | Whether the legend naming each node appears under the diagram. Defaults true. Set false when the user asks for the legend gone; the nodes stay named on the diagram itself and in the hover card. |

## `chord`

Sent as `settings.chord` on the panel.

Chord mapping (displayType "chord"): ONE ROW IS ONE FLOW between two labelled points, and its value is ALREADY AGGREGATED, so GROUP BY the pair in the source SQL and never send raw records. The same label is the same point wherever it appears, in either column. Unlike a sankey, a pair may flow BOTH ways: emit the way back as its own row with the two columns swapped, and both ribbons are drawn. There are no layers, so a chain of hops (A to B to C) draws as unrelated ribbons and is a sankey instead. A flow whose two ends are the same label has no pair to join and is counted below the diagram. Capped at 24 flows, with anything not drawn counted below the diagram. Read immutable/analytics/panel-types/chord.md before configuring one.

Drawn by: `chord`: which pairs exchange volume with each other and which way each exchange leans, one row per direction with its own already-aggregated total, capped at 24.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `sourceKey` | string | yes | Column naming where the flow comes from. Dot paths work. |
| `targetKey` | string | yes | Column naming where the flow ends up. Dot paths work. |
| `valueKey` | string | yes | Column holding the positive numeric amount that moved along this flow. Dot paths work. An empty, non-numeric, zero, or negative amount has no ribbon to draw and is counted as not shown. |
| `showLegend` | boolean | no | Whether the legend naming each point appears under the diagram. Defaults true. Set false when the user asks for the legend gone; each point keeps the name written around its arc. |

## `statetimeline`

Sent as `settings.statetimeline` on the panel.

Timeline mapping (displayType "statetimeline" or "timeline"): ONE ROW IS ONE SPAN of time on a record's line, and every row sharing a label stacks onto that line. "statetimeline" splits each record into its states and REQUIRES a state column; "timeline" asks which records overlap, so it is one row per record and takes no state. The panel draws only spans that carry BOTH ends, so the query owes it a start and an end per span: derive the end with LEAD(started_at) OVER (PARTITION BY record ORDER BY started_at) and COALESCE the open one to now(). Times must be timestamps or ISO strings; a bare epoch number is ambiguous between seconds and milliseconds and is counted as undrawable rather than guessed. Capped at 20 records, with anything not drawn counted below the rows. Read immutable/analytics/panel-types/&lt;statetimeline|timeline>.md before configuring one.

Drawn by: `statetimeline`: how long each record spent in each state, one row per span, up to 20 records; `timeline`: which records overlap in time, one bar per record from its start to its end, up to 20 records.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `labelKey` | string | yes | Column naming the record whose line this span sits on. Rows sharing a value share a line. Dot paths work. |
| `stateKey` | string | no | Column naming the state the record was in for this span. REQUIRED for "statetimeline"; leave it out for "timeline", whose bars are one colour because the question is when records overlap rather than what they were doing. Its distinct values become the colours and the legend, so keep the set small: past eight states the palette repeats a colour. Dot paths work. |
| `startKey` | string | yes | Column holding the timestamp the span began. Dot paths work. |
| `endKey` | string | yes | Column holding the timestamp the span ended. Dot paths work. A span whose end is missing, unparseable, or not after its start is counted below the rows rather than drawn, so close an ongoing state with now() in the query. |
| `showLegend` | boolean | no | Whether the legend naming each state appears under the rows. Defaults true. Set false only when the user asks for the legend gone: a span carries no text, so with the legend hidden its colour can only be read by hovering. Ignored for "timeline", whose bars are one colour. |

## `stat`

Sent as `settings.stat` on the panel.

Stat tile mapping (displayType "stat"): the FIRST data row feeds the tile — the big number, an optional note, and an optional comparison (e.g. [\{ value: 68000 \}] with compareToPrevious renders "+11.1%" with an arrow and a tone; the panel name is the tile's label, there is no separate label). Have the transform return exactly one row. Do NOT compute the delta yourself or format it into the note: the panel derives the change, its colour, and its arrow. Columns named value/note auto-assign when keys are omitted.

Drawn by: `stat`: single KPI number tile, no target — starts at 3 rows by 3 columns, freely resizable.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `valueKey` | string | yes | Column for the big number. |
| `valueType` | string | no | What kind of value it is, from the shared type vocabulary: number, percent, currency, duration, rank, string. This is how the tile formats it AND what it shows when a window has no data — a count reads 0, a percent or rank reads "—". Emit raw numbers from SQL/transform and declare the type here; never format the value into a string yourself. One of: string, number, percent, ratio, currency, duration, rank, boolean, date, object, list, html. |
| `noteKey` | string | no | Optional column for the supporting text under the number. It wraps, and a taller tile shows more lines of it. |
| `compareToPrevious` | boolean | no | true renders the change vs. the previous period — the PLATFORM computes the baseline by running the panel again under the page's date window shifted back one window length. This is THE way to compare against the prior period: never author a second "previous window" source stage or shift $date params in SQL — the platform's auto-window pre-filters rows to the current window, so a hand-rolled previous-period query reads zero rows. Needs the page to declare a dateRange variable and the source a date column; without one (or on allTime) the tile simply shows no comparison. |
| `compareKey` | string | no | Optional column holding a DATA-SUPPLIED baseline on the same row — a target, a budget, a figure from another source. Raw number, same row. NOT for previous-period comparisons (use compareToPrevious). When set, the tile derives and prints the change beside the number: percentage of the baseline's magnitude, the absolute change when the baseline is zero, "No change" when nothing moved. |
| `compareLabelKey` | string | no | Optional column naming the compareKey baseline ("target" → "vs. target" on the tooltip). Reads "vs. the previous period" when it is not set. |
| `direction` | string | no | Which way is good. "higher" (default) for revenue, signups, uptime. "lower" for churn, days to close, cost per lead, response time. The arrow always points the way the number MOVED; this only decides whether that movement is coloured as good or bad, so without it a rising bad number is coloured as success. One of: higher, lower. |

## `trend`

Sent as `settings.trend` on the panel.

Trend tile mapping (displayType "trend"): ONE ROW IS ONE PERIOD, and THE ROW ORDER IS THE PERIOD ORDER — the panel never reorders, so the query must return the periods OLDEST FIRST. The LAST row is the big number, the row before it is what the change is measured against, and every row is a point on the plot under the number. Do NOT compute the delta yourself and do NOT format it into a note: the panel derives the change, decides its colour and its arrow, and prints it the same way on every tile. Capped at 60 points, keeping the most recent ones, with anything not drawn counted below the plot. Read immutable/analytics/panel-types/trend.md before configuring one.

Drawn by: `trend`: one KPI number with how far it moved since the period before — one row per period, oldest first, and the last row is the number.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `valueKey` | string | yes | Column holding the number for that period. Dot paths work (metrics.revenue). Emit raw numbers and declare valueType; never format the value into a string yourself. |
| `valueType` | string | no | What kind of number it is, from the shared type vocabulary. This is how the tile formats the number AND the absolute change, and what it shows when a period has no data — a count reads 0, a percent or rank reads "—". Defaults to number. The change percentage is always printed as a percentage whatever this says. One of: number, percent, ratio, currency, duration, rank. |
| `periodKey` | string | no | Optional column naming each period (a month name, a week label). The change line reads "vs. May" when it is set and "vs. the previous period" when it is not, so set it whenever the query already has the label. |
| `direction` | string | no | Which way is good. "higher" (default) for revenue, signups, uptime. "lower" for churn, days to close, cost per lead, response time. The arrow always points the way the number MOVED; this only decides whether that movement is coloured as good or bad, so without it a rising bad number is coloured as success. One of: higher, lower. |

## `target`

Sent as `settings.target` on the panel.

Value-against-target mapping (displayType "progress", "gauge", or "bullet"): ONE ROW IS ONE TRACK, measured against the target on its own row, so each track can have a different goal. "progress" and "gauge" read the FIRST row and count the rest as not shown; "bullet" reads every row, capped at 20. The target is ALWAYS a column, never a number here. Emit a fixed goal from the query as `250000 AS target` so it re-cooks with the rest of the data. Read immutable/analytics/panel-types/&lt;progress|gauge|bullet>.md before configuring one.

Drawn by: `progress`: how far one number has got toward its goal — first row only, big number over a bar; `gauge`: whether one number sits in a good place within a range — first row only, an arc; `bullet`: who is ahead and who is behind, each row against ITS OWN target — one labelled track per row, capped at 20.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `categoryKey` | string | no | Column naming each track. REQUIRED for "bullet" (a stack of unlabelled tracks answers nothing); ignored by "progress" and "gauge", which use the panel name as the label. |
| `valueKey` | string | yes | Column holding the current numeric value. Dot paths work. |
| `targetKey` | string | yes | Column holding the numeric target for that row. Dot paths work. A row with no target, or a target of zero, has no percentage and is counted as not shown. |
| `noteKey` | string | no | Optional column with a short line of context under the track. |
| `min` | number | no | Where the scale starts. Defaults to 0; set it only when zero is not a meaningful floor (an NPS score, a temperature). |
| `max` | number or string | no | Where the scale ends. Omit and each track ends exactly at its own target, so a full track means the goal was met, which is what you want for "progress" and "bullet". Set it for a "gauge" whose question is about a RANGE (a click-through rate on a 0 to 5 percent scale with the target at 3), otherwise the arc fills at the target and the headroom above it is invisible. "" removes it. |
| `direction` | string | no | Which way is good. "higher" (default) for revenue, deals, uptime. "lower" for churn, cost per lead, response time. Without it a rising bad number is coloured as success. One of: higher, lower. |

## `report`

Sent as `settings.report` on the panel.

Report panel mapping (displayType "report"): the FIRST data row's value at this key is the ENTIRE panel body — one markdown document the transform writes from the data. Omit the block to keep the column already chosen ("report" until one is named; name "report" to go back to it); sending the block without naming a column is rejected. The panel renders markdown only and cannot draw a chart: build the chart as its own panel beside the report and refer to it in the prose. MANDATORY before authoring: knowledge_read immutable/analytics/panel-types/report.md.

Drawn by: `report`: a written narrative about the data, authored as markdown by the transform.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `key` | string | yes | Column holding the report's markdown document. |

## `document`

Sent as `settings.document` on the panel.

Document mapping (displayType "document"): the FIRST data row's value at this key is the ENTIRE document body — \{ html, css, panels \} — that the transform writes from the data. Omit the WHOLE block (or send null) to fall back to a column named "document". MANDATORY before authoring: knowledge_read immutable/analytics/panel-types/document.md, then immutable/analytics/paper-markup.md — markup outside paper markup fails the cook.

Drawn by: `document`: a designed, paginated document — letter/A4 pages, portrait or landscape — that the transform writes in paper markup and the platform prints to PDF.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `key` | string | yes | Column holding the document body \{ html, css, panels \}. |

## `workbook`

Sent as `settings.workbook` on the panel.

Workbook mapping (displayType "workbook"): the FIRST data row's value at this key is the ENTIRE workbook spec — \{ styles, sheets \} — that the transform writes from the data. Omit the WHOLE block (or send null) to fall back to a column named "workbook". MANDATORY before authoring: knowledge_read immutable/analytics/panel-types/workbook.md — a spec outside it fails the cook.

Drawn by: `workbook`: a spreadsheet of one or more sheets — merged cells, fonts, fills, borders, number formats, frozen panes — that the transform writes as a workbook spec; readers work in it as a grid and download it as xlsx.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `key` | string | yes | Column holding the workbook spec \{ styles, sheets \}. |

## `html`

Sent as `settings.html` on the panel.

HTML panel mapping (displayType "html"): the FIRST data row's value at this key is the ENTIRE panel body — one markup document the transform builds from the data. A column named "html" auto-assigns when this is omitted. MANDATORY before authoring: knowledge_read immutable/analytics/panel-types/html.md — it routes to the closed custom-HTML rule set; markup outside those rules fails the cook.

Drawn by: `html`: free-form custom-HTML panel body for bespoke visualizations the standard types cannot express.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `key` | string | yes | Column holding the panel's HTML document. |

## `map`

Sent as `settings.map` on the panel.

Map mapping (displayType "map"): ONE ROW IS ONE MARKER, placed by its latitude/longitude columns. The panel does not geocode — if the rows only carry addresses, the coordinates have to come from the data source or the query first, so say so rather than guessing at coordinates. Read immutable/analytics/panel-types/map.md before configuring one.

Drawn by: `map`: markers on a map — one row per marker, needs latitude/longitude columns.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `latKey` | string | yes | Column holding the latitude (-90 to 90). |
| `lngKey` | string | yes | Column holding the longitude (-180 to 180). |
| `labelKey` | string | no | Column shown when a marker is clicked (a name or address). Omit and markers are unlabelled, which is usually worse — a map of anonymous dots answers nothing. |
| `groupKey` | string | no | Column whose distinct values split the markers into colour-coded groups, each colour assigned automatically and named in a legend (e.g. a status or region column). This is how a map gets coloured layers — do NOT try to assign per-marker colours yourself. |
| `color` | string | no | Hex colour (e.g. "#2563eb") for every marker when there is no groupKey. Omit for the default accent. |
| `basemap` | string | no | "theme" (default) follows the app's light/dark mode and keeps the markers dominant — the right choice for data. "streets" always shows a street map. "satellite" shows imagery, which competes with the markers; use it only when the ask is about the terrain itself. One of: theme, streets, satellite. |
| `projection` | string | no | "globe" (default) draws the earth as a sphere — right for data spanning continents. "flat" is the familiar flat map and is the better choice for a single country, metro, or city, where a curved horizon only wastes space. One of: globe, flat. |
| `cluster` | boolean | no | Group nearby markers into counted clusters that split as you zoom in. Defaults TRUE and should stay on for more than a few dozen markers; turn it off only when every individual marker must be visible at once. |
| `showLegend` | boolean | no | Whether the legend naming each groupKey colour appears under the map. Defaults true. Set false when the user asks for the legend gone. Nothing to hide without a groupKey, whose markers are one colour. |

## `region`

Sent as `settings.region` on the panel.

Region map mapping (displayType "region"): ONE ROW IS ONE COUNTRY, shaded light to dark by its value. The country column may hold an ISO 3166-1 code ("US", "USA") or a country name ("United States") — all three are matched, so do NOT convert between them first. COUNTRIES ONLY: it cannot shade states, provinces, counties, or postcodes, so an ask by state is a bar chart and you should say so. Aggregate to one row per country in the query; a repeat is counted as not shown. Read immutable/analytics/panel-types/region.md before configuring one.

Drawn by: `region`: a value shaded across countries — one row per country, keyed by ISO code or country name.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `regionKey` | string | yes | Column identifying the country: an ISO 3166-1 alpha-2 code ("US", "GB"), an alpha-3 code ("USA", "GBR"), or an English country name ("United States"). Case and punctuation do not matter. Dot paths work. A value that is not a country ("EMEA", "APAC") simply stays unshaded. |
| `valueKey` | string | yes | Column holding the number the country is shaded by. Dot paths work. Zero is a real value and gets the lightest shade; a country absent from the rows is drawn in the separate "no data" grey instead. |
| `projection` | string | no | "flat" (default) is the familiar flat map and the better choice for most comparisons, including a single continent. "globe" draws the earth as a sphere, which suits genuinely worldwide data but hides half the countries at any moment. One of: globe, flat. |
| `showLegend` | boolean | no | Whether the shading scale appears under the map. Defaults true. Set false only when the user asks for the legend gone: with it hidden, nothing on screen says which value a shade stands for, or that the grey countries are unknown rather than zero. |

## `tabs`

Sent as `settings.tabs` on the panel.

Tabs container config (displayType "tabs"): full replacement of the tab list, in order. Each item names a child panel on this page; that panel leaves the grid and renders inside this container when its tab is active. A child may itself be a container (a tab holding a group is a normal shape), but no panel can live in two containers and nothing may contain itself. Pass null to clear the tabs and release every child back to the grid. Read immutable/analytics/panel-types/tabs.md before the first tabs config.

Drawn by: `tabs`: a container whose tab bar switches between other panels on this page.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `items` | array of object | yes | The tabs, in display order. Each item takes: panelId (string), label (string). |

## `group`

Sent as `settings.group` on the panel.

Group container config (displayType "group"): full replacement of the child list. The children render TOGETHER inside one card on the group's own 12-column grid, divided by hairlines — use it when several panels read as one report (a row of stat tiles above their chart), not to save space. Each child leaves the page grid while grouped. A child may itself be a container. Pass null to clear the group and release every child back to the grid. Read immutable/analytics/panel-types/group.md before the first group config.

Drawn by: `group`: a container showing several panels together in one card, divided by hairlines instead of gaps — for panels that read as a single report.

| Setting | Type | Required | What it is |
| --- | --- | --- | --- |
| `items` | array of object | yes | The children and where each sits on the group's own 12-column grid. Each item takes: panelId (string), x (number), y (number), w (number), h (number). |
| `showChildHeaders` | boolean | no | Whether each child keeps its own title row inside the group. Defaults to true — a stat tile's title IS the label of its number. Pass false only for children whose meaning is obvious without labels. |
