Docs

Analytics Display Types

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 or in a panel of 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.

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.

SettingTypeRequiredWhat it is
seriesColorsobjectnoA 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.

SettingTypeRequiredWhat it is
labelKeystringnoColumn 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.

SettingTypeRequiredWhat it is
fieldsarray of objectnoThe 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.

SettingTypeRequiredWhat it is
xKeystringyesColumn used as the category label (x axis). Line charts usually use a date column.
seriesarray of objectyesThe 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).
orientationstringnoBar 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.
barModestringnoBar 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.
curvestringnoLine, 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.
showLegendboolean or stringnoWhether 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.
showPointsbooleannoLine, 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.
showValuesbooleannoPrint 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.
singleAxisbooleannoOmit 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.

SettingTypeRequiredWhat it is
periodKeystringyesColumn 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.
seriesKeystringyesColumn 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.
valueKeystringnoColumn 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.
rankKeystringnoColumn 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.
orderstringnoWhich 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/<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.

SettingTypeRequiredWhat it is
xKeystringyesColumn holding the numeric value on the horizontal axis, usually the suspected cause or input (e.g. "days_to_close"). Dot paths work.
yKeystringyesColumn holding the numeric value on the vertical axis, usually the outcome (e.g. "amount"). Dot paths work.
labelKeystringyesColumn 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.
sizeKeystringnoBubble 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.
colorKeystringnoBubble 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.
showLegendbooleannoBubble 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.
xThresholdKeystringnoQUADRANT 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.
yThresholdKeystringnoQUADRANT ONLY, required there: column holding where the horizontal divider stands, on the same terms as xThresholdKey.
zoneTopRightstringnoQUADRANT 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.
zoneTopLeftstringnoQUADRANT ONLY: what to call the zone above the horizontal divider and left of the vertical one.
zoneBottomRightstringnoQUADRANT 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").
zoneBottomLeftstringnoQUADRANT 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.

SettingTypeRequiredWhat it is
labelKeystringyesColumn naming the slice. Dot paths work.
valueKeystringyesColumn holding the positive numeric slice value. Dot paths work.
showLegendbooleannoWhether 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.
compactLegendbooleannoWhether 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.
legendValuestringnoWhat 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.

SettingTypeRequiredWhat it is
labelKeystringyesColumn naming the tile. Dot paths work.
valueKeystringyesColumn 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/<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.

SettingTypeRequiredWhat it is
rowKeystringyesColumn 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.
columnKeystringyesColumn 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.
valueKeystringyesColumn 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.

SettingTypeRequiredWhat it is
rowKeystringyesColumn 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.
columnKeystringyesColumn 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.
statusKeystringyesColumn 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.
showLegendbooleannoWhether 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.

SettingTypeRequiredWhat it is
dateKeystringyesColumn 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.
valueKeystringyesColumn 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.

SettingTypeRequiredWhat it is
categoryKeystringyesColumn naming the step. Dot paths work.
valueKeystringyesColumn 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.
orientationstringnoHow 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.
percentBasestringnoWhich 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.

SettingTypeRequiredWhat it is
categoryKeystringyesColumn naming the step. Dot paths work.
valueKeystringyesColumn 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.
totalKeystringnoOptional 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.

SettingTypeRequiredWhat it is
sourceKeystringyesColumn naming where the flow comes from. Dot paths work.
targetKeystringyesColumn naming where the flow ends up. Dot paths work.
valueKeystringyesColumn 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.
showLegendbooleannoWhether 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.

SettingTypeRequiredWhat it is
sourceKeystringyesColumn naming where the flow comes from. Dot paths work.
targetKeystringyesColumn naming where the flow ends up. Dot paths work.
valueKeystringyesColumn 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.
showLegendbooleannoWhether 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/<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.

SettingTypeRequiredWhat it is
labelKeystringyesColumn naming the record whose line this span sits on. Rows sharing a value share a line. Dot paths work.
stateKeystringnoColumn 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.
startKeystringyesColumn holding the timestamp the span began. Dot paths work.
endKeystringyesColumn 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.
showLegendbooleannoWhether 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.

SettingTypeRequiredWhat it is
valueKeystringyesColumn for the big number.
valueTypestringnoWhat 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.
noteKeystringnoOptional column for the supporting text under the number. It wraps, and a taller tile shows more lines of it.
compareToPreviousbooleannotrue 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.
compareKeystringnoOptional 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.
compareLabelKeystringnoOptional column naming the compareKey baseline ("target" → "vs. target" on the tooltip). Reads "vs. the previous period" when it is not set.
directionstringnoWhich 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.

SettingTypeRequiredWhat it is
valueKeystringyesColumn 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.
valueTypestringnoWhat 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.
periodKeystringnoOptional 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.
directionstringnoWhich 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/<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.

SettingTypeRequiredWhat it is
categoryKeystringnoColumn 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.
valueKeystringyesColumn holding the current numeric value. Dot paths work.
targetKeystringyesColumn 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.
noteKeystringnoOptional column with a short line of context under the track.
minnumbernoWhere the scale starts. Defaults to 0; set it only when zero is not a meaningful floor (an NPS score, a temperature).
maxnumber or stringnoWhere 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.
directionstringnoWhich 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.

SettingTypeRequiredWhat it is
keystringyesColumn 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.

SettingTypeRequiredWhat it is
keystringyesColumn 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.

SettingTypeRequiredWhat it is
keystringyesColumn 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.

SettingTypeRequiredWhat it is
keystringyesColumn 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.

SettingTypeRequiredWhat it is
latKeystringyesColumn holding the latitude (-90 to 90).
lngKeystringyesColumn holding the longitude (-180 to 180).
labelKeystringnoColumn 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.
groupKeystringnoColumn 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.
colorstringnoHex colour (e.g. "#2563eb") for every marker when there is no groupKey. Omit for the default accent.
basemapstringno"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.
projectionstringno"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.
clusterbooleannoGroup 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.
showLegendbooleannoWhether 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.

SettingTypeRequiredWhat it is
regionKeystringyesColumn 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.
valueKeystringyesColumn 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.
projectionstringno"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.
showLegendbooleannoWhether 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.

SettingTypeRequiredWhat it is
itemsarray of objectyesThe 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.

SettingTypeRequiredWhat it is
itemsarray of objectyesThe 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).
showChildHeadersbooleannoWhether 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.