Designing a Report from Data

Last update: September 14, 2026
•
1 min to read

🧪 Experimental feature (preview). This feature is under active development and released early so you can try it and give feedback. Behaviour, options, and results can change without notice, and it isn’t covered by the usual support and stability guarantees. Avoid relying on it for production work.

Overview

You’ve been handed a description of some records and asked to design a Report for them. The output is one JSON object — a report definition — that the view renders directly. You are not writing code and not calling an API. You are choosing which columns to show, how to show each one, and what the report says about them.

This page is the recipe. It assumes you know the format from Writing Report Definitions and the charts from the Report Chart Reference; it won’t repeat either. What it adds is judgement: reading a profile, matching shapes to charts, and keeping the result coherent.

A built-in auto-builder already produces a report from the same profile with no model in the loop. That’s the floor. Your output should beat it — because you can read column names and sample values, and it can’t. What it does is described at the end so you know what “better” means.

What you’re given

A column profile: one entry per column of the records in view.

FieldMeaningHow to use it
nameThe column key, exactly as the data spells itGoes into dimension, measure, seriesField, fields, and so on. Copy it byte for byte.
kindnumber · date · boolean · text · emptynumber → a measure, or an axis for scatter/histogram. date → a time axis. text/boolean → a dimension or series split. empty → skip.
fillRate0–1, the fraction of rows with a valueBelow about 0.3 the column is mostly blank; charting it is charting noise.
distinctDistinct non-empty valuesA good category axis has 2–12. 1 is a constant. Close to the row count is an identifier.

You’ll also know the row count, the source label (what the page calls these records), and usually a few sample rows so you can see real values, name the domain correctly, and check that a column means what its name suggests.

kind is inferred by majority vote over the values: strings that parse as numbers count as numbers, and a string counts as a date only if it parses and looks like one (has a year, or a d/m shape). A column of phone numbers will profile as number; a column of free text with the odd date in it stays text. Trust the name and the samples over the label when they disagree.

Read the profile

Walk the columns once and sort them into piles. These thresholds are the auto-builder’s; they’re sensible defaults, not laws.

Reject a column when any of these hold:

  • kind is empty, or fillRate is below 0.3.
  • It’s a surrogate key: named id, key, guid, or ending in .id / .guid — or distinct / rows is 0.9 or more. Identifiers describe nothing; summing them is nonsense and grouping by them gives one bar per row.
  • distinct is 1. One value makes one bar.

Then bucket what survives:

  • Dimensions — text or boolean with 2–12 distinct values. A number with a small distinct (a 1–5 rating, a year column) can serve as a dimension too. Rank them: the sweet spot is 3–8 values with a high fill rate; a boolean gets a small nudge because a two-way split is always readable.
  • Measures — number, not a key, distinct > 1. Rank by fill rate, then by how much they vary. Ask what the number is: amounts, durations, counts-of-something, scores and rates are measures; codes, years and phone numbers are not, whatever the profile says.
  • Time axes — every date column, best fill rate first. One is enough for a trend; two dates that mean start and end unlock a gantt.

For each measure decide the roll-up: avg when the name says rate, percent, score, ratio or average; sum for amounts, quantities and durations; count when you’re not rolling up a measure at all. When unsure, count. A count is never wrong; a sum of the wrong column is.

From shape to chart

Match the shape of the question to a family, then pick the specific chart. The reference has the fine print on each.

You haveAskReach for
one dimensionhow big is each group?bar (2–8 values), horizontalBar (long labels or 8–12), pie/donut (2–6, part-of-whole), lollipop, pareto (which few dominate)
one dimension + a measurehow much per group?the same charts with sum/avg; treemap when values differ wildly; waterfall to show accumulation
two dimensionshow does A break down by B?groupedBar, stackedBar (totals matter), heatmap (with a measure, or dense counts), sunburst, radar (few of each)
a datehow does volume change over time?timeSeries (line), timeBar (columns), cumulative (to-date), calendar (daily density over months)
a date + a measurehow does the amount change over time?timeSeries/timeBar with sum or avg
two dates that are a start and an endwhen did each thing run?gantt
a measure with spreadhow are the values distributed?histogram (overall), boxplot (per group)
two varying measuresdo they move together?scatter, bubble (with a third)
2–4 categoricals that form a progressionwhere does the flow go?sankey (weighted), flow (flowchart feel), network (connections)
a headline numberwhat’s the total?hosted numberLabel; figure for “the top value and the rest”; gauge for a total against a round ceiling
the records themselveswhich ones?list — and filter it to the ones that matter

Three charts are traps in this context. line and area sort by value rather than order, so they are never right for dates — use the time family. sparkline is fed the same value-sorted roll-up and always slopes down. funnel orders stages by size, so it only reads as a funnel when the stages genuinely shrink in sequence.

Two more need a semantic the data rarely carries: burndown wants a remaining-work series, and bullet compares each group to the mean of the groups. Skip them unless that’s exactly the point.

Build the report

Three to six tiles. Fewer feels empty; more than six at 300 px is a wall. One question per tile.

A reliable ladder, adding rungs while the inputs exist and stopping at six:

  1. Headline — the best dimension as a bar, size L. It’s the single most important breakdown; give it the full width.
  2. The number — a hosted numberLabel (count of records, or the sum of the main measure), size S. Add a filter if there’s an obvious subset worth counting (“open”, “overdue”, “this year”). Two or three of these across one row work well.
  3. Second dimension — pie/donut if it has ≤ 6 values, else horizontalBar, size M.
  4. The measure — the main measure rolled up by the headline dimension, size M. A different chart than tile 1 so the two don’t rhyme.
  5. Two dimensions together — heatmap when there’s a measure, groupedBar or stackedBar on counts, size M.
  6. Time — timeSeries or timeBar on the date column, size L. Pick granularity from the span: days for a few weeks, weeks for a quarter, months for a year or two, quarters or years beyond that. Aim for 6–24 buckets.
  7. The records — a list of the interesting subset (filter + groupBy), autoGrow: true, size M or L, last.

Then, if room remains and it’s genuinely useful: a boxplot of the measure by the headline dimension, a histogram of the measure, a scatter of two real measures, a gantt if there are start/end dates, a calendar if the data is daily and dense.

Don’t chart the same dimension twice in two chart types — pick the better one. Reach past bar/pie only when the shape fits. When in doubt, a clean bar beats a forced exotic.

Size for rhythm. L for the headline and the time trend; M in pairs; S in threes for numbers. In grid, a row of S S S or M M or one L fills cleanly; an odd S leaves a gap. Use column layout only for a one-per-row narrative of two or three tiles.

Style

Palettes. One categorical palette for every chart that names groups; one sequential palette for every chart that encodes magnitude (heatmap, calendar, gauge, figure, waterfall, the time family). Two palettes per report, not thirteen. tableau + viridis is the neutral default and reads on light and dark cards alike. ember + plasma is warmer; slate + cividis is quieter; earthen + warm suits parchment.

Theme. Choose a canvas theme for the audience, not the data: white prints best; parchment, sage, sky, blush warm up a screen report; dark-blue, forest, plum, black for a wall display or a dark-mode reader. Let the tiles take the theme’s card default. Override a single tile’s theme only for deliberate contrast — a dark KPI on a light canvas, say.

Legends. Turn the legend off (showLegend: false) on bar charts with short labels; the axis does the job and the plot gets the space. Keep it on for pies, donuts and treemaps, where the legend is the labelling. orientation can alternate left/right down the report for variety, but it’s a small thing.

Titles. Every tile gets a title in plain Title Case that names what it shows: Events by Type, Total Amount by Region, Amount: Category × Region, Events per Month, Open Cases. Not Bar chart. Split camelCase and snake_case column names into words. A filtered tile already shows its filter as a subtitle, so the title doesn’t need to repeat it.

Logo. hubstream or dataspace when the report is going out as a PDF; none otherwise.

Title and summary

The title block ships with placeholder text. Replacing it with real, data-grounded copy is part of the job.

title.title names the domain, from the source label or the dominant entity: Case Events Overview, Training Sessions — Q1 2026, Open Investigations by Region. Never Report, Data Report or a chart name. When the source label is generic (“Report data”, “records”), name the report after the dimensions it’s built around instead: Event Type & Region Report.

title.body is one or two sentences of HTML stating the facts a reader wants first: how many records, the key dimensions, the time span if there’s a date, the main total if there’s a measure, and what the tiles show. Use the numbers you were given; don’t invent any.

<p>247 case events across 6 event types, 4 categories and 5 regions, Jan–Jun 2026.
Total amount 1,234,567. The charts show volume by type, the category split,
amount by category and region, and activity per month.</p>

Degrade gracefully: drop the time clause when there’s no date, the amount clause when there’s no measure. Stay factual — you can’t see the rendered charts, so don’t describe trends you haven’t computed. <b> the one number that matters most; leave the rest plain.

Filters as a design tool

A tile filter turns a generic chart into a specific one, and it’s the cheapest way to add meaning:

  • A numberLabel with Status equals Open is “open cases”, not “cases”.
  • A list with DueDate lt 2026-09-14 and groupBy: "AssignedTo" is an overdue list by owner.
  • A bar with Amount gt 10000 is “where the big money is”.

Filters compare case-insensitively; gt/lt are numeric when both sides are numbers and string-wise otherwise (so ISO dates work, slash dates don’t). Use the sample rows to get the value spelling right — "On Hold" with the space. A filter you can’t ground in a real value is worse than none.

Worked example

Input. Source label Case Events, 247 rows:

namekindfillRatedistinct
Idnumber1.00247
EventTypetext0.986
Categorytext0.914
Regiontext0.885
Statustext1.003
Amountnumber0.74210
CreatedOndate1.00180
ClosedOndate0.4196
Notestext0.22240

Samples show Status values Open, Closed, On Hold; CreatedOn runs January to June 2026.

Reasoning. Id is a key: out. Notes is sparse and near-unique: out. EventType (6), Category (4), Region (5) and Status (3) are clean dimensions; EventType has the most values in the sweet spot and leads. Amount is a real measure — sum. CreatedOn is the time axis; six months of daily data wants month buckets. ClosedOn is half-filled, but paired with CreatedOn it’s a start/end — a gantt of the closed cases would work, but at 247 rows the gantt caps at 60 and it isn’t the story, so skip it. Two clean categoricals plus a measure earn a heatmap. Status is the obvious filter for a headline number and a list.

Output.

{
  "version": 1,
  "layout": "grid",
  "theme": "white",
  "title": {
    "show": true,
    "title": "Case Events Overview",
    "body": "<p>247 case events across 6 event types, 4 categories and 5 regions, Jan–Jun 2026. The charts show volume by type, the category split, amount by category and region, and events per month, with the open cases listed by region.</p>",
    "logo": "hubstream"
  },
  "tiles": [
    { "chartType": "bar", "sourceId": "context", "dimension": "EventType", "measure": "", "aggregation": "count",
      "size": "L", "palette": "tableau", "theme": "light", "orientation": "left", "showLegend": false, "title": "Events by Type" },

    { "kind": "hubtile", "tile": "numberLabel", "sourceId": "context", "measure": "", "aggregation": "count",
      "label": "Open cases", "filter": { "field": "Status", "operator": "equals", "value": "Open" },
      "size": "S", "palette": "tableau", "theme": "light" },
    { "kind": "hubtile", "tile": "numberLabel", "sourceId": "context", "measure": "", "aggregation": "count",
      "label": "On hold", "filter": { "field": "Status", "operator": "equals", "value": "On Hold" },
      "size": "S", "palette": "tableau", "theme": "light" },
    { "kind": "hubtile", "tile": "numberLabel", "sourceId": "context", "measure": "Amount", "aggregation": "sum",
      "label": "Total amount",
      "size": "S", "palette": "tableau", "theme": "light" },

    { "chartType": "donut", "sourceId": "context", "dimension": "Category", "measure": "", "aggregation": "count",
      "size": "M", "palette": "tableau", "theme": "light", "orientation": "right", "title": "By Category" },
    { "chartType": "heatmap", "sourceId": "context", "dimension": "Category", "seriesField": "Region",
      "measure": "Amount", "aggregation": "sum",
      "size": "M", "palette": "viridis", "theme": "light", "orientation": "left", "title": "Amount: Category × Region" },

    { "chartType": "timeBar", "sourceId": "context", "dimension": "CreatedOn", "granularity": "month",
      "measure": "", "aggregation": "count",
      "size": "L", "palette": "viridis", "theme": "light", "orientation": "left", "showLegend": false, "title": "Events per Month" },

    { "kind": "list", "title": "Open Cases by Region", "sourceId": "context",
      "fields": ["EventType", "Category", "Amount", "CreatedOn"], "groupBy": "Region", "maxItems": 15, "navigate": true,
      "filter": { "field": "Status", "operator": "equals", "value": "Open" }, "autoGrow": true,
      "size": "L", "palette": "tableau", "theme": "light" }
  ],
  "annotations": []
}

Eight tiles is the top of the range, but three of them are the small number row; the reader sees a headline, a strip of figures, two supporting charts, a trend and a list. Note the discipline: categorical tiles share tableau, magnitude tiles share viridis; no dimension appears twice; every column name is copied from the profile; the body states real counts and the real span.

Anti-patterns

  • Charting an id, a constant, or a sparse column. Seven identical bars and Other; one bar; a chart of blanks.
  • Summing a number that isn’t a quantity. Years, codes, ratings-as-sum. Count instead, or avg a rating.
  • line/area/sparkline on a date. They sort by value. Use timeSeries, timeBar, cumulative.
  • The same dimension in two charts. A bar and a pie of EventType says nothing twice.
  • A different palette on every tile. Rainbow reports read as decoration. Two palettes.
  • A sequential ramp on unordered categories, or distinct hues on a heatmap. The colour lies about the data.
  • Pie or radar past six or eight values. The slices vanish; the polygons tangle.
  • theme: "light". Not a theme id; it loads as white. Use the nine names.
  • Placeholder title and body. “Report” with an empty summary is a draft, not a report.
  • Annotations placed blind. You can’t see the pixels. [] unless asked.
  • Exotic charts for their own sake. A sankey over columns that aren’t a progression, a gantt from one date column, a burndown of event counts. When the shape doesn’t fit, use a bar.

The floor: what the auto-builder does

With no model available, the view builds a report deterministically from the same profile. Knowing it tells you what to beat.

It differs from the rules above in one way: it also reads the data model. Dimensions come from the entity’s single-select value lists first (promoted fields, then the model’s field order), then low-cardinality free text, then Yes/No fields; measures and dates come from the field’s model type, and the entity’s own date beats Date Created / Updated. The records only veto: the “Not specified” placeholder doesn’t count as a value, so a field that is mostly placeholder (under 30 % real values) is never charted. Created By / Updated By are never charted. Then it walks a fixed ladder: a bar of the best dimension (L), a figure of the same dimension (M), a pie or horizontalBar of the second dimension, a bar of the main measure by the first dimension, a heatmap or groupedBar of the first two dimensions, a timeSeries or timeBar of the date (L), then a third dimension or a boxplot, padding with more value-list or free-text bars to six tiles. It always uses tableau for categorical tiles and viridis for magnitude tiles, alternates orientation left/right, derives per-tile titles (By Event Type, Total Amount by Region, Amount: Category × Region), and writes a factual summary: record and field counts, the top three values of the headline dimension with their counts, the date span, and the measure total.

Where you can do better: it only knows a dozen chart types (no calendar, gantt, list or hosted tiles); it can’t read names, so it will happily sum a Year column; it never filters; it draws the same dimension twice (bar and figure); and it writes theme: "light", which isn’t a theme id. Read the names, use the time family for dates, add a filtered number and a list, keep one dimension per tile, and pick a real theme.

Checklist before you emit

  • Every column name is copied exactly from the profile; every sourceId is "context".
  • No dimension is an id, a constant, or below 0.3 fill; every dimension has 2–12 values.
  • count tiles carry measure: ""; sum/avg tiles name a genuine quantity.
  • Dates use the time family with a granularity giving 6–24 buckets — never line/area/sparkline.
  • Family fields are complete: seriesField on series/heatmap/sunburst, stageFields (≥ 2) on flows, xMeasure/yMeasure on scatter, startField/endField/labelField on gantt.
  • One categorical palette, one sequential palette, matched to what each chart encodes.
  • theme is one of the nine ids; tile theme left to the default unless contrasting on purpose.
  • 3–6 substantive tiles (small numbers don’t count against that), one L headline, sizes that fill rows.
  • Every tile has a real title; filters are grounded in sample values.
  • title.title names the domain; title.body states real counts, span and totals in short HTML.
  • version: 1, valid layout, annotations: [].