Chapter 16 of 40

Footer models: totals and key figures

A footer that sums up exactly the rows you see – and follows filter and sorting.

After this chapter you can

  • write a footer area model
  • sum the filtered rows only once
  • find columns by property
  • since 1.1.94: totals in one option, sums of the selection

A footer is an area model

footerData (chapter 14 (Array of arrays)) is static. A footer that shows totals of exactly the rows the user sees is a small class of its own: it extends AreaModel and reads the displayed rows of the body with AreaModelObjectArray.getFilteredRows() – after filter and sorting.

All rows pass the external filter and the sort order; the result feeds the body, the footer totals, copy and the Excel export of the view all rows getAllRows() filter externalFilterFunction sort comparator / sortByColumn displayed rows getFilteredRows() body (visible part) footer totals copy · Excel view externalFilterChanged() runs the filter again and keeps the last sort order. ExcelService with getAllRows() ignores filter and sorting.

The footer model

src/model/order-totals-footer.ts
import { AreaModel, type AreaModelObjectArray, type CellRendererIf, type ColumnDefIf } from "@guiexpert/table";
import type { Order } from "./orders";
// Episode 016 – a footer that follows filter and sorting: it reads the rows the body currently shows.
export class OrderTotalsFooterModel extends AreaModel {
constructor(
private readonly getBody: () => AreaModelObjectArray<Order>,
private readonly columnDefs: ColumnDefIf[],
private readonly rowHeight = 34) {
super();
this.areaIdent = "footer";
}
override getRowCount(): number { return 1; }
override getRowHeight(_rowIndex: number): number { return this.rowHeight; }
// Episode 025 – an own area model chooses its renderers itself: here the footer renderer of the column
override getCellRenderer(_rowIndex: number, columnIndex: number): CellRendererIf | undefined {
return this.columnDefs[columnIndex]?.rendererMap?.footer;
}
private cache?: { rows: Order[]; length: number; qty: number; total: number };
override getValueAt(_rowIndex: number, columnIndex: number): any {
const t = this.totals();
switch (this.columnDefs[columnIndex]?.property) { // by property, not by position
case "customer": return `${t.length.toLocaleString("en")} orders`;
case "quantity": return t.qty;
case "total": return Math.round(t.total * 100) / 100;
}
return "";
}
/** Episode 031 – values changed (editing, live data): sum again on the next repaint. */
invalidate(): void {
this.cache = undefined;
}
// sum once per filter result, not once per repaint
private totals() {
const rows = this.getBody().getFilteredRows();
if (this.cache?.rows !== rows || this.cache.length !== rows.length) {
let qty = 0, total = 0;
for (const o of rows) { qty += o.quantity; total += o.total; }
this.cache = { rows, length: rows.length, qty, total };
}
return this.cache;
}
}
  • One row: getRowCount() returns 1, getRowHeight() its height.
  • By property, not by position: the switch looks at columnDefs[columnIndex].property. Hidden or moved columns (chapter 18 (Fixed columns)) don't break it.
  • Sum once per filter result: getValueAt() is called for every visible footer cell on every repaint – the cache keeps that cheap even for 100,000 rows.
  • getCellRenderer() lets the footer render the total as euro (chapter 25 (Cell renderers)).

Plug it in

const tableModel = TableFactory.createTableModel({
rows: orders,
columnDefs: columns,
footerAreaModel: new OrderTotalsFooterModel(
() => tableModel.getBodyModel() as AreaModelObjectArray<Order>, columns)
});

The footer needs the body model, which only exists after createTableModel – so it gets a function (() => tableModel.getBodyModel()) instead of the model itself. Pass it as footerAreaModel (CreateTableModelPara).

Live Totals that follow the filter
Screenshot: Totals that follow the filter Screenshot: Totals that follow the filter
Type “berlin” – the footer shows the number of orders and the sums of only these rows.

Built in: totals in one option

The footer model above is how it works inside – since 1.1.94 the table has it built in. CreateTableModelPara.footerAggregates builds an AreaModelAggregateFooter: the keys are column properties, the values aggregators – "sum", "count", "avg", "min", "max", "countDistinct", "first", "last" or your own.

const tableModel = TableFactory.createTableModel({
rows: orders,
columnDefs: columns,
footerAggregates: [
{ label: "Sum", aggregates: { quantity: "sum", total: "sum" } },
{ label: "Average", aggregates: { quantity: "avg", price: "avg", total: "avg" } }
]
});
  • Follows filter and sorting like the model above, and computes once per change – never while scrolling. Filter, sorting, editing, TableApi.updateCells() and TableApi.repaint() recompute it; no invalidate() of your own.
  • Labels: "100,000 rows" by default in the first column without a total, or your own per row. A short form for one row: footerAggregates: { quantity: "sum", total: "sum" }.
  • Numbers stay numbers: the footer shows "27,481,033.5", Excel gets the number (chapter 24 (Excel export)); a footer renderer of the column gets it, too.
  • The same aggregators without a table: aggregateColumns.

A status bar for the selection – count, sum and average of the selected cells, like in a spreadsheet – is TableApi.getSelectionAggregates() in onSelectionChanged:

listener.onSelectionChanged = () => {
const a = api.getSelectionAggregates(); // the selected body cells
ctx.info(a.capped ? "selection too large"
: a.numericCount ? `count ${fmt(a.count)} · sum ${formatAggregateValue(a.sum)} · average ${formatAggregateValue(a.avg)}`
: "select cells with numbers – drag, Shift-click, Ctrl+A");
};
Live Totals in one option, sums of the selection
Screenshot: Totals in one option, sums of the selection Screenshot: Totals in one option, sums of the selection
Type “berlin”, then drag over Qty … Total – the toolbar shows the sum of the selected cells.

Next

A model without any array behind it – two million rows computed on demand: chapter 17 (Own AreaModel).

API used in this chapter

Showcases