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.

pascal
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.

pascal
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.

pascal
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.

pascal
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.

pascal
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.

  1. Create a folder GroupedReport with an empty GroupedReport.dpr, and open it in the IDE, which creates the project file. In the project options, add the src folders of Atlas Analytics and Atlas VCL to the search path. Start the program with the console directive and the units:

    pascal
    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;
  2. Add the data. AddColumn sets each column's kind, and ImportCSV checks every value against it. The header must match the column names exactly.

    pascal
    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;
  3. Add two helpers so the program checks its own results. A failed check raises, and the main block turns it into exit code 1.

    pascal
    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;
  4. Start the main block and define the report. GroupBy names the dimension and the measures. Each measure becomes a result column named by its caption. The count measure needs no field.

    pascal
    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)]);
  5. Run it. Execute returns a new table with one row per region, in the order the regions first appear, and you free it.

    pascal
          // 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;
  6. 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.

    pascal
          // 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');
  7. Remove the filter and export HTML. The page has no external resources, escapes every value and right-aligns numeric cells.

    pascal
          // 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)');
  8. Save the definition as JSON, load it back, and prepare the print document, which you also free.

    pascal
          // 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;
  9. Turn the same definition into a detail report with SelectFields, which clears the groups and measures. Then close the main block.

    pascal
          // 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.
  10. Run the program (Ctrl+Shift+F9 runs it without the debugger). The console shows the following and the exit code is 0:

    Example
    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
    PASS

    Open sales-by-region.html next to the executable to see the report. To adapt the program, group by Product instead of Region, or write the JSON to a file and load it in another program with LoadJSON. 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.