YOUSEFUL / DELPHI
Atlas Analytics: FAQ, examples and tutorial
Learning center · Component reference
Atlas Analytics adds report definitions and linked dashboards to Atlas VCL data.
A TAtlasReportDefinition turns a TAtlasTable into a filtered detail list or a
grouped summary, and returns it as a new table, a self-contained HTML page or a
paginated print document. A TAtlasDashboard shows KPI cards, bar charts and
grids that all follow one filtered snapshot of a table and refresh together
when the table changes. Add Atlas.Analytics (filters, measures and the
TAtlasAnalytics query functions), Atlas.Reports and Atlas.Dashboard to
uses, together with Atlas.Data for the table. Atlas Analytics requires
Atlas VCL, which supplies the table, grid, chart and printing classes. Put the
src folders of both products on the search path. AI-assisted reporting is
not implemented.
FAQ
What does Atlas Analytics contain? Two components on the Atlas
Analytics palette page: TAtlasReportDefinition (non-visual) and
TAtlasDashboard (a scroll box of widget cards). The supporting types are the
TAtlasFilter and TAtlasMeasure
records, the TAtlasWidgetDefinition record, the
static query functions of TAtlasAnalytics and the
EAtlasAnalyticsError exception. The design
package is AtlasPlusDesign370.bpl. It needs the Atlas VCL runtime package and
AtlasPlusRuntime370.bpl. Installing it in the IDE has not been tested. The
sample projects link the units statically instead.
Where does the data come from? Only from a TAtlasTable. Nothing connects
to a database or runs SQL: fill the table with AddRow or ImportCSV first.
Every query, report and dashboard reads the table's current view, so the table's
own SetFilter and Sort apply. Nothing ever modifies the table. Queries and
reports return new tables with read-only columns, and you free them.
How are filters evaluated? All filters must match (AND). Field names and
text comparisons ignore case. Numbers compare numerically. Dates compare as
YYYY-MM-DD text, which is date order. afContains is a case-insensitive
substring test and works on text columns only. An empty value means a missing
value: it takes part in afEqual and afNotEqual but never passes afLess,
afLessEqual, afGreater or afGreaterEqual. The filter value is normalized
for the column kind first, so 1 matches true in a boolean column. A value
that does not fit the column, such as lots for a number, raises
EAtlasDataError.
How are measures calculated? amCount counts every row, including rows with
missing values. amSum, amMinimum, amMaximum and amAverage skip missing
values, and the average divides by the number of values actually present.
Groups use the exact cell text, so East and east are two groups. Groups
appear in the order their first row appears. Without dimensions there is always
one total row: on empty input the count and sum are 0 and the other measures
are missing. Results are stored in invariant format without rounding, for
example 12216.6666666667.
Can I design reports visually or export to PDF or Excel? No. There is no band
designer, no XLSX or PDF export, no scripting and no query language. A report
produces a TAtlasTable (Execute), an HTML page (RenderHTML) or a
TAtlasPrintDocument (PreparePrint) that you can preview, render or print.
The HTML page repeats its table header on every printed page. Output on a
physical printer has not been certified.
Is there AI-assisted reporting? No. It is not implemented, pending a decision on the provider. No service is called and no key is used. Reports are deterministic: the same definition over the same table gives the same result.
What is stored when I save a report or a dashboard? ToJSON on a report
stores the title, subtitle, fields, dimensions, measures and filters.
Enumerations are stored as ordinals. ToJSON on a dashboard stores the widget
definitions and the dashboard filters. Neither stores the data or the source
table. LoadJSON validates everything before replacing anything and raises
EAtlasAnalyticsError on failure. A report checks its field names when it
runs, while a dashboard checks its widgets against its current Source while
loading. In the form designer only Title, Subtitle and the dashboard's
Source are published, so define fields, groups and widgets in code or load
them from JSON.
How do the dashboard widgets stay in sync? Each refresh takes one snapshot
of the source (its current view, then the filters from SetFilters) and builds
the data of every widget before any widget changes. Adding, editing or deleting
rows, or changing the table's own filter or sort, refreshes everything. If that
automatic refresh fails, the widgets keep their previous data and LastError
holds the message. Explicit calls (Source, SetFilters, Refresh,
AddWidget, LoadJSON) raise the error instead and keep the previous state.
Freeing the source table clears the widgets.
What layout does the dashboard use? Equal cards 300 pixels high, in
ClientWidth div 400 columns (at least one) with 16-pixel gaps, in the order
they were added. The box scrolls vertically. There is no drag-and-drop layout
designer and no per-card size. A dashboard holds at most 64 widgets. Charts are
bar charts with one series. KPI cards format their number with
FormatFloat('#,##0.##') in the user's locale and show No data when there is
no value.
Is there a browser or ASP.NET version? The product also contains original
web code, which is outside the Delphi samples here.
TAtlasAnalytics.DatasetJSON writes the version 1 dataset: fields with name
and kind, and rows as arrays in field order, with null for missing values.
The JavaScript module web/wwwroot/atlas-data.mjs exports validateDataset,
filterRows, summarize and sortRows. The
<atlas-dashboard src="/api/dataset" metric-field="Revenue" dimension-field="Region">
custom element shows linked KPIs, category drill-down, text search, numeric
sorting and empty states, and emits atlas-view-change and atlas-error
events. Its endpoint must be on the page's origin, and its CSS comes from
/site.css. An ASP.NET Core example, which needs the .NET 10 SDK, serves
read-only /api/dataset?region=West and /api/report?region=West endpoints and
a server-rendered Blazor DashboardView. It is documented for loopback use,
without authentication or remote deployment. Large datasets are rendered all
at once, with no pagination or virtualization.
Can I use these classes from a background thread? They contain no locking.
Use the dashboard on the main thread like any VCL control. TAtlasAnalytics and
TAtlasReportDefinition only read the source table, so they can run elsewhere
as long as nothing changes that table at the same time.
Examples
Unless stated otherwise, these examples use the six-row sales table from the
tutorial: columns Region, Product, Revenue (number), Units (number) and
Booked (date).
Summarize a table without a report definition. TAtlasAnalytics.Aggregate
returns a new table that you free.
uses System.SysUtils, Atlas.Data, Atlas.Analytics;
procedure PrintAverageSale(Sales: TAtlasTable);
var
Totals: TAtlasTable;
begin
Totals := TAtlasAnalytics.Aggregate(Sales, [], [
TAtlasMeasure.Create('Revenue', 'Average sale', amAverage),
TAtlasMeasure.Create('', 'Orders', amCount)]);
try
Writeln('Average sale ', Totals.Cell(0, 0), ' over ', Totals.Cell(1, 0), ' orders');
finally
Totals.Free;
end;
end;The program prints Average sale 12216.6666666667 over 6 orders.
Write the rows of one region as an HTML page. SelectFields makes a detail
report, and the filter is applied before the columns are chosen.
uses System.SysUtils, System.IOUtils, Atlas.Data, Atlas.Analytics, Atlas.Reports;
procedure WriteRegionDetail(Sales: TAtlasTable; const Region, FileName: string);
var
Report: TAtlasReportDefinition;
begin
Report := TAtlasReportDefinition.Create(nil);
try
Report.Title := Region + ' sales';
Report.SelectFields(['Booked', 'Product', 'Revenue', 'Units']);
Report.SetFilters([TAtlasFilter.Create('Region', Region)]);
TFile.WriteAllText(FileName, Report.RenderHTML(Sales), TEncoding.UTF8);
finally
Report.Free;
end;
end;For West the page has the heading West sales, four columns, two rows and
the footer 2 result rows.
Group by two dimensions. You get one row for each combination that occurs, in the order it first appears.
uses Atlas.Data, Atlas.Analytics, Atlas.Reports;
function RevenueByRegionAndProduct(Sales: TAtlasTable): TAtlasTable;
var
Report: TAtlasReportDefinition;
begin
Report := TAtlasReportDefinition.Create(nil);
try
Report.GroupBy(['Region', 'Product'], [TAtlasMeasure.Create('Revenue', 'Revenue', amSum)]);
Result := Report.Execute(Sales);
finally
Report.Free;
end;
end;The result has six rows: East, Desktop, 18000, West, Desktop, 22500,
East, Service, 6400, North, Desktop, 12000, West, Service, 9600 and
North, Service, 4800. The caller frees it.
Show a filtered dashboard in a window. The form owns the dashboard, but the table must outlive the form.
uses Vcl.Forms, Vcl.Controls, Atlas.Data, Atlas.Analytics, Atlas.Dashboard;
function ShowWestDashboard(Sales: TAtlasTable): TForm;
var
Dashboard: TAtlasDashboard;
begin
Result := TForm.CreateNew(nil);
Result.Caption := 'West sales';
Result.SetBounds(0, 0, 900, 700);
Dashboard := TAtlasDashboard.Create(Result);
Dashboard.Parent := Result;
Dashboard.Align := alClient;
Dashboard.Source := Sales;
Dashboard.AddWidget(TAtlasWidgetDefinition.Create(awKPI, 'Revenue', 'Revenue'));
Dashboard.AddWidget(TAtlasWidgetDefinition.Create(awChart, 'Revenue by product', 'Revenue', amSum, 'Product'));
Dashboard.SetFilters([TAtlasFilter.Create('Region', 'West')]);
Result.Show;
end;The KPI card shows 32,100 (with your locale's separators), and the chart has
two bars: Desktop 22500 and Service 9600.
Accept a threshold typed by a user. Filter values are checked against the
column kind, so catch EAtlasDataError.
uses System.SysUtils, Atlas.Data, Atlas.Analytics;
function CountAbove(Sales: TAtlasTable; const Threshold: string): string;
var
Rows: TAtlasTable;
begin
try
Rows := TAtlasAnalytics.Select(Sales, [], [TAtlasFilter.Create('Revenue', Threshold, afGreater)]);
try
Result := IntToStr(Rows.RowCount) + ' sales above ' + Threshold;
finally
Rows.Free;
end;
except
on E: EAtlasDataError do
Result := 'Not a number: ' + E.Message;
end;
end;CountAbove(Sales, '10000') returns 3 sales above 10000.
CountAbove(Sales, '10,000') returns
Not a number: Invalid finite number: 10,000, because values use the invariant
format, with no thousands separators.
Tutorial
Sample project: Grouped report to HTML.
Goal: define a grouped sales report, run it over a table, check the totals and export it to HTML and a print document without opening a window.
Create a folder
GroupedReportwith an emptyGroupedReport.dpr, and open it in the IDE, which creates the project file. In the project options, add thesrcfolders of Atlas Analytics and Atlas VCL to the search path. Start the program with the console directive and the units:program GroupedReport; // Atlas Analytics quick start: define a grouped report, run it over a // TAtlasTable, export it to HTML and prepare a print document, all without // a window. The program checks its own results and exits with code 1 on any // mismatch. {$APPTYPE CONSOLE} uses System.SysUtils, System.IOUtils, Atlas.Data, Atlas.Analytics, Atlas.Reports, Atlas.Printing;Add the data.
AddColumnsets each column's kind, andImportCSVchecks every value against it. The header must match the column names exactly.function SalesTable: TAtlasTable; begin Result := TAtlasTable.Create(nil); try Result.AddColumn('Region'); Result.AddColumn('Product'); Result.AddColumn('Revenue', ackNumber); Result.AddColumn('Units', ackNumber); Result.AddColumn('Booked', ackDate); Result.ImportCSV( 'Region,Product,Revenue,Units,Booked' + sLineBreak + 'East,Desktop,18000,12,2026-09-01' + sLineBreak + 'West,Desktop,22500,15,2026-09-02' + sLineBreak + 'East,Service,6400,8,2026-09-03' + sLineBreak + 'North,Desktop,12000,8,2026-09-04' + sLineBreak + 'West,Service,9600,12,2026-09-05' + sLineBreak + 'North,Service,4800,6,2026-09-06'); except Result.Free; raise; end; end;Add two helpers so the program checks its own results. A failed check raises, and the main block turns it into exit code 1.
procedure Check(Condition: Boolean; const Message: string); begin if not Condition then raise Exception.Create('Check failed: ' + Message); end; procedure Expect(const Expected, Actual, What: string); begin Check(Expected = Actual, What + ': expected "' + Expected + '", got "' + Actual + '"'); end;Start the main block and define the report.
GroupBynames the dimension and the measures. Each measure becomes a result column named by its caption. The count measure needs no field.var Source, Summary, Detail: TAtlasTable; Report: TAtlasReportDefinition; Document: TAtlasPrintDocument; HTML, FileName, JSON: string; I: Integer; begin try Source := SalesTable; Report := TAtlasReportDefinition.Create(nil); try Report.Title := 'Sales by region'; Report.Subtitle := 'Revenue, units and average sale per region'; Report.GroupBy(['Region'], [ TAtlasMeasure.Create('Revenue', 'Revenue', amSum), TAtlasMeasure.Create('Units', 'Units', amSum), TAtlasMeasure.Create('Revenue', 'Average sale', amAverage), TAtlasMeasure.Create('', 'Orders', amCount)]);Run it.
Executereturns a new table with one row per region, in the order the regions first appear, and you free it.// 1. Execute returns a new table that the caller owns. Summary := Report.Execute(Source); try Writeln('Grouped report: ', Summary.RowCount, ' rows, ', Summary.ColumnCount, ' columns'); for I := 0 to Summary.RowCount - 1 do Writeln(' ', Summary.Cell(0, I):6, ' revenue ', Summary.Cell(1, I):6, ' units ', Summary.Cell(2, I):3, ' average ', Summary.Cell(3, I):6, ' orders ', Summary.Cell(4, I)); Check(Summary.RowCount = 3, 'three regions'); Expect('East', Summary.Cell(0, 0), 'groups keep first-seen order'); Expect('24400', Summary.Cell(1, 0), 'East revenue'); Expect('20', Summary.Cell(2, 0), 'East units'); Expect('12200', Summary.Cell(3, 0), 'East average sale'); Expect('2', Summary.Cell(4, 0), 'East orders'); Expect('32100', Summary.Cell(1, 1), 'West revenue'); Expect('16800', Summary.Cell(1, 2), 'North revenue'); finally Summary.Free; end;Add a filter. It removes the North service sale of 4800 before grouping, so North keeps one order. The source table still has all six rows.
// 2. Filters narrow the rows before they are grouped; the source is untouched. Report.SetFilters([TAtlasFilter.Create('Revenue', '5000', afGreaterEqual)]); Summary := Report.Execute(Source); try Expect('12000', Summary.Cell(1, 2), 'North revenue with filter'); Expect('1', Summary.Cell(4, 2), 'North orders with filter'); finally Summary.Free; end; Check(Source.RowCount = 6, 'source table unchanged');Remove the filter and export HTML. The page has no external resources, escapes every value and right-aligns numeric cells.
// 3. RenderHTML writes a complete, escaped, printable page. Report.SetFilters([]); HTML := Report.RenderHTML(Source); FileName := TPath.Combine(TPath.GetDirectoryName(ParamStr(0)), 'sales-by-region.html'); TFile.WriteAllText(FileName, HTML, TEncoding.UTF8); Check(Pos('<title>Sales by region</title>', HTML) > 0, 'HTML title'); Check(Pos('<th scope="col">Average sale</th>', HTML) > 0, 'HTML column header'); Check(Pos('<td class="number">24400</td>', HTML) > 0, 'HTML numeric cell'); Check(Pos('<p>3 result rows</p>', HTML) > 0, 'HTML row count'); Writeln('Wrote ', FileName, ' (', Length(HTML), ' characters)');Save the definition as JSON, load it back, and prepare the print document, which you also free.
// 4. The definition round-trips through JSON. JSON := Report.ToJSON; Report.LoadJSON(JSON); Expect(JSON, Report.ToJSON, 'JSON round trip'); Writeln('Definition: ', JSON); // 5. PreparePrint returns a paginated document the caller owns. Document := Report.PreparePrint(Source); try Check(Document.PageCount = 1, 'one printed page'); Writeln('Print document: ', Document.PageCount, ' page, title "', Document.Title, '"'); finally Document.Free; end;Turn the same definition into a detail report with
SelectFields, which clears the groups and measures. Then close the main block.// 6. SelectFields turns the same definition into a detail report. Report.SelectFields(['Region', 'Product', 'Revenue']); Report.SetFilters([TAtlasFilter.Create('Product', 'Desktop')]); Detail := Report.Execute(Source); try Check(Detail.RowCount = 3, 'three Desktop rows'); Check(Detail.ColumnCount = 3, 'three selected columns'); Writeln('Detail report: ', Detail.RowCount, ' Desktop rows'); finally Detail.Free; end; finally Report.Free; Source.Free; end; Writeln('PASS'); except on E: Exception do begin Writeln(E.ClassName, ': ', E.Message); ExitCode := 1; end; end; end.Run the program (Ctrl+Shift+F9 runs it without the debugger). The console shows the following and the exit code is 0:
Grouped report: 3 rows, 5 columns East revenue 24400 units 20 average 12200 orders 2 West revenue 32100 units 27 average 16050 orders 2 North revenue 16800 units 14 average 8400 orders 2 Wrote ...\sales-by-region.html (1453 characters) Definition: {"version":1,"title":"Sales by region","subtitle":"Revenue, units and average sale per region","fields":[],"dimensions":["Region"],"measures":[{"field":"Revenue","caption":"Revenue","kind":1},{"field":"Units","caption":"Units","kind":1},{"field":"Revenue","caption":"Average sale","kind":4},{"field":"","caption":"Orders","kind":0}],"filters":[]} Print document: 1 page, title "Sales by region" Detail report: 3 Desktop rows PASSOpen
sales-by-region.htmlnext to the executable to see the report. To adapt the program, group byProductinstead ofRegion, or write the JSON to a file and load it in another program withLoadJSON. The linked dashboard sample shows the same data as live KPI, chart and grid widgets.
Troubleshooting
EAtlasAnalyticsError: Unknown field: ... when a report runs. A field,
dimension, measure or filter names a column that is not in the table. Names
ignore case but must otherwise match. LoadJSON and the definition methods do
not check names, so the error appears only in Execute, RenderHTML or
PreparePrint. A measure other than amCount with an empty field reports
Unknown field: with nothing after the colon.
EAtlasDataError: Invalid finite number (or a date or boolean message) from a
filter. Filter values are checked against the column kind. Write numbers with
a . decimal point and no thousands separators, dates as YYYY-MM-DD and
booleans as true, false, 1 or 0. Catch EAtlasDataError as well as
EAtlasAnalyticsError wherever user input reaches a filter.
EAtlasDataError: Duplicate column name: .... Every result column needs a
distinct name, compared without case. Common causes: two measures with the same
caption, a caption equal to a dimension name, a field listed twice in
SelectFields, or a chart widget whose title equals its dimension (the title is
the measure caption). Rename the caption or the title.
Numeric measure requires a numeric field or Contains requires a text field. Sum, minimum, maximum and average need an ackNumber column. Use
amCount for other columns. afContains works only on ackText columns. Use
afEqual or an ordered operator for numbers and dates.
A group appears twice, for example East and east. Grouping uses the exact
cell text, while filters ignore case. Clean the values in the source table if
they should form one group.
A KPI card shows No data. The dashboard has no Source, or the filters
leave no rows for a minimum, maximum or average measure. A sum shows 0 and a
count shows 0 in the same situation.
The dashboard stopped updating after the table changed. An automatic refresh
failed, for example because a chart value exceeds 1E100 in magnitude. The
widgets keep the previous consistent data. Show LastError, fix the data, then
call Refresh or change the table again. LastError clears after the next
successful refresh.
Widgets or report groups are missing after the form is loaded. Only Title,
Subtitle and Source are published. Widget definitions and report fields,
groups and filters are not saved with the form. Create them in code (for
example in the form's constructor) or keep their ToJSON output and call
LoadJSON.