Lattice Grid Buy a licence

angular data grid · server-side with duckdb

Angular Grid: Server-Side DuckDB

createPushdownSource plus duckdbAdapter push the grid's filter, group and total work down into a real analytical engine instead of computing over rows already sitting on screen, and fullDataset says the subtotal is over the whole matching set, not one page of it.

Also in: React

Install and import

npm install @toclocoinc/lattice-grid
# DuckDB itself loads from a CDN at runtime, not from this package
# free on localhost; a production domain needs a licence key (see /pricing/)

The file

readings.component.ts

// readings.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { bootstrapApplication } from '@angular/platform-browser';
import * as Lattice from '@toclocoinc/lattice-grid';
import '@toclocoinc/lattice-grid/css';
import { LatticeGridComponent, provideLattice } from '@toclocoinc/lattice-grid/angular';

const { createGrid, createPushdownSource, duckdbAdapter } = Lattice;

// The share of a group's rows inside tolerance. Under fullDataset the scope
// is the whole group, so a subtotal is a whole-group rate, not a page's.
const inSpecRate = (values: unknown[]) => {
  let seen = 0, ok = 0;
  for (const v of values) { if (typeof v === 'boolean') { seen++; if (v) ok++; } }
  return seen ? ok / seen : null;
};

const COLUMNS = [
  { field: 'plant', title: 'Plant', filter: { type: 'set' }, group: { enabled: true, index: 0 }, total: 'count' },
  { field: 'line', title: 'Line', filter: { type: 'set' } },
  { field: 'product', title: 'Product' },
  { field: 'bore_mm', title: 'Bore', type: 'millimetres', spec: { lower: 9.95, upper: 10.05, target: 10 }, total: 'avg' },
  { field: 'cycle_seconds', title: 'Cycle', type: 'seconds', total: 'avg' },
  { id: 'in_spec_rate', field: 'in_spec', title: 'In-spec rate', type: 'number', format: { style: 'percent', decimals: 2 }, total: inSpecRate },
  { field: 'taken_at', title: 'Taken', type: 'dateString' },
];

@Component({
  selector: 'app-readings',
  standalone: true,
  imports: [CommonModule, LatticeGridComponent],
  template:
    '<div style="font-size:13px;color:var(--ink-2)">{{ status }}</div>' +
    '<lattice-grid *ngIf="gridConfig" [config]="gridConfig" style="display:block;height:520px;margin-top:12px"></lattice-grid>',
})
export class ReadingsComponent implements OnInit, OnDestroy {
  status = 'Starting DuckDB in the browser...';
  gridConfig: any = null;
  private disposed = false;
  private connection: any;
  private worker: any;

  // DuckDB is your own engine, loaded from a CDN; the grid imports none of
  // it. duckdbAdapter takes the connection you made, so the source drives a
  // full analytical engine while the grid package stays dependency-free.
  async ngOnInit() {
    try {
      this.status = 'Loading DuckDB from a CDN...';
      const duckdb = await import('https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm');
      const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
      this.worker = await duckdb.createWorker(bundle.mainWorker);
      const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(duckdb.LogLevel.ERROR), this.worker);
      await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
      if (this.disposed) return;
      this.connection = await db.connect();

      // The engine resolves the URL, so it must be absolute.
      const file = new URL('/demo-data/readings.parquet', location.origin).href;
      const adapter = duckdbAdapter({ connection: this.connection, from: `read_parquet('${file}')` });

      // fullDataset pulls the whole matching set in once and holds it, so
      // the subtotals and the grand total are over every matching row, not
      // one page of them.
      const source = createPushdownSource({
        adapter, compute: Lattice, pageSize: 200,
        fullDataset: { enabled: true, maxRows: 250000 },
      });
      this.gridConfig = {
        rowKey: 'reading_id',
        toolPanel: { side: 'left', panels: ['filters', 'columns'] },
        grandTotalRow: 'bottom',
        groupFooter: true,
        showTotalInHeader: true,
        columns: COLUMNS,
        source,
      };
      this.status = 'Rows pulled into the tab and grouped by plant.';
    } catch {
      this.status = 'DuckDB could not start here: it needs WebAssembly and a CDN connection.';
    }
  }

  ngOnDestroy() {
    this.disposed = true;
    try { this.connection?.close?.(); } catch {}
    try { this.worker?.terminate?.(); } catch {}
  }
}

bootstrapApplication(ReadingsComponent, {
  providers: [provideLattice({ createGrid })],
});

See it running

Building…
Loading a live grid…

Open this demo's Angular tab →

Working in Angular

The grid is not in the template until the source is ready. Building the DuckDB connection is asynchronous, so *ngIf="gridConfig" keeps <lattice-grid> out of the DOM until ngOnInit has finished setting it.

Clean up in ngOnDestroy. Closing the DuckDB connection and terminating its worker there means navigating away mid-query does not leave a worker running in a tab nobody is looking at, and a late resolution checks disposed before touching the destroyed component.

compute: Lattice. The pushdown source still needs the grid's own compute helpers for anything DuckDB itself is not asked to do, which is why the whole package namespace is passed in alongside the adapter.

Related