Lattice Grid Buy a licence

api reference

The model layer: JavaScript Data Grid API

grid.ai and the intent layer, grid.diagnostics, formulas, grid.licence, and the module and utility exports.

API reference › The model layer

Every warning this grid can print, explained. The message quoted here is one of 748 catalogued at the warnings reference, each with what it means and the specific change that clears it.

grid.ai

The grid composes a prompt describing its own columns and operators, you send it to whichever model you like, and it validates the reply before anything is applied. It makes no network call and has no default model.

MethodReturnsDescription
schema(opts?)objectThe schema: columns, types, permitted operators.
prompt(text, opts?)stringThe message to send, schema included.
plan(reply, opts?)objectParse and validate. Unknown columns and operators are rejected with a reason; valid actions in the same reply are kept. plan.describe() renders it in plain English for confirmation.
apply(plan)objectApplies an approved plan as one undo entry, labelled with what it did.

Actions: setFilters, setSort, groupBy, showColumns, hideColumns, setQuick, clear. Nothing else is executable, so a model cannot be talked into an operation the vocabulary does not contain. docs/AI-SKILL.md is the reference to hand your model.

Ask-your-data (modules/ai)

The opt-in AI module (createAI, UMD LatticeGridAI) turns a question into a validated read-only query spec, runs it in the grid's own engine over the grid.ai skill layer, and - on apply - fans the answer to any router-attached viewers (a chart, a KPI tile) through the Data Router's load(). It uses the same BYO ask() seam as the rest of the module: the grid makes no model call and holds no key. Ask-your-data is read-only; a write/mutation the model asks for is refused and never executed (writes are a separate, human-gated feature).

MethodReturnsDescription
query(question, opts?)Promise<result>Ask the model, validate the reply into a read-only spec. result.describe() shows the resolved query; nothing applies until result.apply() (or autoApply).
applyQuery(result, opts?)objectApply a reviewed result. Re-gated at the seam: an unsafe plan is refused. opts.router fans the answer to other viewers.
askBar(el, opts?)controllerMount the ask/review/apply bar with an "auto-apply safe reads" toggle (off by default). Mounts only when createAI's enable allows 'query'/'ask' (all allowed when omitted); query() itself is never gated.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'region' }, { field: 'amount', type: 'number' }],
  rows: [{ id: 1, region: 'EMEA', amount: 100 }, { id: 2, region: 'AMER', amount: 300 }, { id: 3, region: 'EMEA', amount: 200 }],
});
// Your model returns a schema-constrained SPEC, never rows. WE run it, read-only.
const reads = createAI(grid, { ask: async () => ({ actions: [{ type: 'setFilters', filters: { col: 'region', op: 'eq', value: 'EMEA' } }] }) });
const result = await reads.query('EMEA only');
result.apply();
const rows = grid.rows.data().length;
// A write verb is refused by the read-only gate and never executed.
const writes = createAI(grid, { ask: async () => ({ actions: [{ type: 'setCells', edits: [] }] }) });
const write = await writes.query('change the data');
return `${rows} rows; write ${write.ok ? 'allowed' : 'refused'}`;

AI as a governed actor (modules/ai, writes)

The governed actor lets the model propose edits - a single NL-targeted change (“set the Network Upgrade project to In Progress”) or a bulk cleanup - that a human previews as a before/after diff and approves. The model NEVER writes. On approval the edit applies through the grid's own gate, exactly like a person's edit: a grid cell edit via grid.edit.setCells(writes, 'cell', { origin: 'ai' }) (the beforeEdit veto), a Kanban card move via board.move(…, { origin: 'ai' }) (the beforeMove veto). The AI can never bypass the gate: a host beforeEdit/beforeMove handler that calls preventDefault() (or vetoes async) stops the write, and nothing persists. Writes carry origin: 'ai' on the before-event payload, so a host can allow a person's edit while vetoing the AI's - policing AI writes distinctly. Approved edits are optimistic and revert on a source reject through the shipped write-back (there is no separate beforeCommit event; the edit gate is beforeEdit, and the optimistic/revert half is grid.edit.settle).

NL targeting and any bulk edit bind to the current filtered view (grid.rows.data()), never the whole table implicitly; a target not in the view is surfaced for an explicit opt-in widen, not edited silently. A human label is resolved to the column's stored option value; an unknown label is rejected, not coerced. An ambiguous match (more than one row) surfaces its candidates for the user to pick.

MethodReturnsDescription
propose(instruction, opts?)Promise<proposal>Ask the model for structured edits, validate + resolve them against the current view, and return a reviewable proposal with a before/after diff. Nothing is written. opts.widen opts into the full dataset; opts.board routes a Kanban move.
applyProposal(proposal, opts?)Promise<report>Apply an approved proposal through the gate (origin: 'ai'). A vetoing before-handler stops it; the report gives applied/vetoed.
actorBar(el, opts?)controllerMount the propose → review-diff → approve bar, stating the scope. Mounts only when createAI's enable allows 'actor' (allowed when omitted); propose() itself is never gated.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const status = { options: [{ id: 'todo', label: 'To do' }, { id: 'doing', label: 'In Progress' }] };
const seed = () => createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'name' }, { field: 'status', lookup: status, edit: { enabled: true } }],
  rows: [{ id: 'r1', name: 'Network Upgrade', status: 'todo' }, { id: 'r2', name: 'Payroll', status: 'todo' }],
});
// A MOCK ask() returns a structured PROPOSAL - never a write, never rows.
const ask = async () => ({ structured: { edits: [{ match: 'Network Upgrade', column: 'status', value: 'In Progress' }] } });

// Propose: a before/after diff, nothing written yet.
let grid = seed();
let ai = createAI(grid, { ask });
const proposal = await ai.propose('set the Network Upgrade project to In Progress');
const d = proposal.diff[0];
const before = `${d.oldDisplay} then ${d.newDisplay}`;

// A vetoing beforeEdit handler on the AI write => NOTHING persists.
grid.on('beforeEdit', (e) => { if (e.origin === 'ai') e.preventDefault('reviewed elsewhere'); });
await ai.applyProposal(proposal);
const vetoed = grid.rows.byKey('r1').data.status;

// Approve on a grid with no veto => the edit lands through the gate.
grid = seed();
ai = createAI(grid, { ask });
await ai.applyProposal(await ai.propose('set the Network Upgrade project to In Progress'));
const approved = grid.rows.byKey('r1').data.status;

return `diff ${before}; vetoed kept ${vetoed}; approved wrote ${approved}`;

grid.diagnostics

What the grid is doing, as data: render counts and their causes, memory layout, operation and provider timing, event listener counts, effective configuration, and a list of things that look like mistakes.

This is the API; the devtools panel is a consumer of it. Built in that order deliberately: instrumentation shaped by a UI tends to report what is convenient to display rather than what is true, and an API that only exists behind a panel cannot be asserted against in a test.

// the assertion this exists to make possible
const before = grid.diagnostics.renders().dom.cellWrites;
grid.filters.set({ col: 'status', op: 'eq', value: 'active' });
await nextFrame();
const written = grid.diagnostics.renders().dom.cellWrites - before;
expect(written).toBeLessThan(200);
MethodReturnsDescription
snapshot()objectEverything, in one structure.
renders()objectCounts by cause, the last render's phase timings, DOM write counters and viewport state.
store()objectPer-column backing kind and byte footprint, total bytes, rows against physical slots, tombstoned rows.
operations()objectCount, mean and worst per operation kind, with a bounded sample of recent calls.
providers()objectCalls, errors, in-flight count and latency per provider, with failures retained.
events()objectListener count per event type.
config()object{ effective, supplied, defaulted }, which values you chose and which the grid chose.
warnings()object[]Everything flagged, newest first, each with a stable id.
dismiss(id)voidHide a warning for this session. Not permanently.
bundle()objectA support bundle. Contains no row data.
checkOptions(options)booleanTrue when an options object changed identity without its contents changing.
record(kind, detail)voidRecord your own operation, so custom work appears alongside the grid's.
reset()voidZero the counters. Warnings and configuration are left alone.

The support bundle

bundle() returns configuration, query state, timing history, warnings, provider statistics, version and environment. It never contains row data, cell values or column values, and says so in its own contains field. The guarantee is the point: a bundle that had to be read for confidential content before sending is a bundle nobody sends.

Warnings

Each carries a stable id a support conversation can name, a plain description, and the specific values involved. Two sources are merged: checks run against the grid, and everything the grid has reported through its own one-per-cause warnings.

IdWhat it means
options-identity-churnAn options object rebuilt on every parent render. A wrapper comparing by identity will tear the grid down each time.
duplicate-row-keysTwo rows share a key. Presents as "the wrong row updated", never as an error.
query-references-unknown-columnA filter or sort names a column that does not exist. Silently matches nothing.
listener-count-growingProbable subscription leak in the host. Presents as gradual slowdown.
main-thread-eligible-for-workerA large operation ran on the main thread despite the worker threshold.
slow-providerA provider took more than a second to answer.

The warnings reference

Every developer-facing line the grid prints to the console - each one prefixed [lattice] - is de-duplicated on a key, and that key is the identifier to quote. The package ships the whole list as data: docs/warnings.json, inside @toclocoinc/lattice-grid and in the public repository, with one entry per key giving the message template, the module it comes from, what happened and what the grid did about it, and what to change. A * in an identifier stands for a value filled in at run time - a column id, a chart id, an event name - so icon:* is one entry covering every column the icon renderer refuses.

// look up the line you are reading in the console
const { warnings } = await import('@toclocoinc/lattice-grid/docs/warnings.json', { with: { type: 'json' } });
const entry = warnings.find((w) => w.id === 'rowKey.missing');
console.log(entry.meaning, entry.fix);

The devtools panel

An optional module. It imports nothing (the grid is handed to it) so deployments that never load it pay nothing.

import { createGrid } from '@toclocoinc/lattice-grid';
import { createDevtools } from '@toclocoinc/lattice-grid/modules/devtools';

const grid = createGrid(el, config);
createDevtools({ grid });          // Ctrl+Shift+D collapses it

Nine tabs over the API above, a compact vitals strip to leave open while working, and a render heat overlay that tints cells as they are written: blue for a new value, red for a cell rewritten with the value it already held. The second colour is the one worth chasing: it is work the grid did not need to do, and no counter alone will tell you where it is.

The panel observes and never mutates. A configuration editor would create a second path into state that has to be kept correct forever, so there is not one. Nothing leaves the browser: there is no telemetry, and the bundle is produced only when you ask for it.

grid.licence

There is one Lattice Grid and every copy is feature-identical. No community edition, no pro tier, no feature held back behind a key. A licence removes the trial watermark; that is the whole of what it does.

Free to develop against, licensed to deploy. A grid on localhost, or any loopback host: needs no key at all. On any other domain an unlicensed grid still renders everything and carries a small trial watermark linking to latticegrid.dev. Nothing ever refuses to render: the failure worth avoiding is a broken production screen, and no licensing state is worth causing one.

// Before creating a grid.
LatticeGrid.setLicence('LG1.…');           // your key; setLicense also works

const grid = LatticeGrid.createGrid(el, config);
grid.licence.state();      // 'licensed' | 'localhost' | 'trial'
MethodReturnsDescription
set(key)objectInstall a key for the process. Returns the provisional verdict; the check is asynchronous and licence:changed fires when it settles.
state()string'licensed', 'localhost' or 'trial'.
info()object{ valid, reason, issuedTo, expires, product, terms }. expires is undefined for a perpetual key (the default) and an ISO date only for one deliberately issued with a term. terms is 'commercial' (the default) or 'non-commercial' for the free tier.
watermark()booleanWhether the trial mark is showing.
readyPromiseSettles when the licence check finishes.

Domains

A key names the hosts it covers. *.acme.com matches app.acme.com, a.b.acme.com and acme.com itself, a wildcard that refused the apex would be a puzzle rather than a licence. A bare acme.com matches only itself, and a key naming no domains is valid anywhere. Matching ignores case and a trailing dot.

HostNo keyKey for *.acme.com
localhost, 127.0.0.1, ::1, *.localhosteverything, no markeverything, no mark
app.acme.comeverything, trial watermarkeverything, no mark
acme.comeverything, trial watermarkeverything, no mark
other.example.orgeverything, trial watermarkeverything, trial watermark

.local, .internal and private IP ranges are not exempt. They are ordinary LAN names, and a corporate intranet is a deployment like any other.

Getting a key

Keys are issued from latticegrid.dev. A key is issued per domain rather than per developer or per seat: name the domains the grid will run on and one key covers every developer, every build and every user on them.

Keys are perpetual by default. A key carries no expiry unless one was deliberately issued (a trial, a time-boxed pilot) so the ordinary key is valid until the domains it names change, not one that quietly lapses on a date nobody is tracking.

Checking a key needs no network. There is no licence server, no call home, and nothing that can fail at three in the morning, a key carries its own answer and the grid reads it locally, fresh on every load. A key for the wrong domain, or a key that will not read at all, does the same thing a genuinely expired trial key does: log one console warning and show the watermark.

Install the key before creating a grid. Setting one later still works, licence:changed fires and the watermark is removed, but the first frames of the grid will carry it.

The terms marker

A key's payload may carry a terms marker: 'non-commercial' for the free tier, and 'commercial' - or absent, which is the same thing - for everything else. grid.licence.info() reports it as terms. The marker is carried and reported, never enforced by the software: non-commercial terms are enforced by the licence terms, not by the grid. There is no phone-home, no feature difference, and no network call of any kind - a non-commercial key behaves exactly like any other valid key.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

// No key installed, and absent means 'commercial': the status API reports it.
const grid = createHeadlessGrid({ columns: [{ id: 'n', field: 'n' }], rows: [{ id: '1', n: 'a' }] });
const terms = grid.licence.info().terms;
grid.destroy();

return terms;

Formulas

A leading = in a numeric cell is a formula. The grid stores what it comes to.

=5 + 5
=quantity * unitPrice
=[Unit Price] * 1.2
=ROUND(quantity * unitPrice, 2)
=IF(quantity > 10, "bulk", "single")
=SUM(readings)              // an array property on the row

References name columns of the same row, not cells, a grid sorts, filters, groups and pages, so A1 would mean a different row from one moment to the next. Matching is on field or title, ignoring case and spacing; bracket a name that contains spaces. A property with no column of its own is reachable too.

GroupFunctions
MathsSUM, AVERAGE/AVG, MIN, MAX, COUNT, PRODUCT, ABS, SQRT, POWER, MOD
RoundingROUND, ROUNDUP, ROUNDDOWN, FLOOR, CEILING
LogicIF, AND, OR, NOT, COALESCE
TextCONCAT, LEN, UPPER, LOWER, TRIM, LEFT, RIGHT
StatisticsMEDIAN, PERCENTILE, QUARTILE1, QUARTILE3, IQR, STDEV, STDEVP, VAR, VARP, COUNTDISTINCT

The statistical functions use R type 7 quantiles, the same definition as the totals row, grid.statistics and the distribution formatting rules, so the four never disagree about what a median is. PERCENTILE reads 90 and 0.9 as the same request. Over an empty set they return a number rather than null, because a formula is arithmetic and has to keep composing.

Operators + - * / ^ with parentheses, comparison for IF, and postfix %. ^ is right-associative and unary minus binds tighter than it, so -2^2 is 4: Excel's answer rather than mathematics'.

// Your own functions, on top of the built-in library.
createGrid(el, {
  formulaFunctions: {
    MARGIN: ([revenue, cost]) => (revenue - cost) / revenue,
  },
});

The result is stored, not the expression. A formula is a way of entering a value: like 1,200, (50) or 12%, and it commits as one undo step with the column's own validation. Persisting a formula and recalculating it when a dependency changes is a separate feature; referencesOf() is exported for anyone building it.

No eval, no new Function. A formula is text a user typed, so evaluating it with the JavaScript engine would let anyone who can edit a cell run code in your page. It is a hand-written parser and the only callable things are the functions above.

Bare arithmetic is not a formula. 2-1 is a plausible product code and 1/2 a plausible date, so both are refused rather than guessed at. Declare a formula with =.

Module and utility APIs

Everything else the package declares. Each entry is public: it is declared in the type definitions, which is what makes it a promise.

Locale and messages

NameSignatureDescription
DEFAULT_LOCALEstringThe locale used when none is configured and none can be read from the page.
EN_GBRecord<string, string | object>The built-in British English catalogue; the one every other locale falls back to for keys it does not carry.
MESSAGE_KEYSReadonlyArray<string>Every key in the built-in catalogue, so a custom catalogue can be built against the real list.
auditCatalogue(catalogue) => { missing, unknown }Report which keys a custom catalogue is missing and which it invents, to check a translation before shipping it.
registerLocale(tag, catalogue) => booleanRegister a catalogue for a locale tag. The per-locale entry points call this; locale then resolves to the registered catalogue.
resolveLocale(configured?, declared?, fallback?) => stringSettle which locale applies: what you configured, then what the page declares, then the fallback.
createMessages(opts?) => MessagesBuild a message catalogue. A partial set lays over the built-in British English one.
MessagesclassThe catalogue itself. t(key, params) resolves one message; configure() replaces the set at runtime.
formatList(items, locale?, type?) => stringJoin a list the way the locale does, as a conjunction or a disjunction.

Formulas and units

NameSignatureDescription
evaluateFormula(text, params?) => FormulaResultEvaluate one expression. The same closed language the grid uses: no eval, no host access.
looksLikeFormula(text) => booleanWhether a pasted or typed value should be treated as a formula.
parseUnit(text, opts?) => number | nullRead a value with a unit on it back to a number in the base unit. null when it will not parse.
formatUnit(value, opts?) => stringThe inverse: render a base-unit number on the ladder the column asked for.
UNIT_SYSTEMSRecord<string, UnitDescriptor[]>Every registered unit system, by name.

Statistic tiles

NameSignatureDescription
deltaOf(value, baseline) => objectThe change between a value and its baseline, as a tile shows it.
toneOf(direction, goodWhen) => stringWhich way to colour a change, given whether a rise is good news.

Licensing and modules

NameSignatureDescription
licenceInfo() => LicenceInfoWhat the current key says: product, holder, expiry. licenseInfo is the same function under the American spelling.
licenceState() => LicenceInfoWhether the current host is licensed, and why not if it is not. licenseState is its alias.
registerModules(modules, opts?) => voidInstall optional modules once, for every grid on the page.
CONSOLE_ACTIVATIONstringThe console incantation that activates a trial key.

Ingesting rows, and menus

NameSignatureDescription
ingestSync(rows, plan?, opts?) => objectBuild a column store and an inferred schema from raw rows, synchronously. The result records why each column got the type it did.
ContextMenuclassThe menu the grid opens on right-click, reusable for a menu of your own. open(p) places it.

The charts module

NameSignatureDescription
TYPESreadonly ChartType[]Every chart type name createChart accepts.
SCHEMESRecord<string, readonly string[]>The built-in colour schemes, by name.
PALETTEreadonly string[]The default series colours.
registerScheme(name, colours) => voidAdd a colour scheme, or replace one of ours under the same name.
resolveScheme(spec?) => objectSettle which scheme a chart will draw with.
setDefaultScheme(name) => voidChange the scheme every chart uses unless it asks for another.
schemeNames() => string[]Every scheme name available, built-in and registered.

The framework adapters

React, Vue and Svelte build their public surface from the same list, so an event becomes a prop or an emit without either side keeping a second copy.

NameSignatureDescription
EVENT_NAMESreadonly string[]Every event the grid declares. Mirrors the EventName union, and the build fails if the two diverge.
handlerName(event) => stringThe React prop for an event: cell:changed becomes onCellChanged.
dashedName(event) => stringThe Vue and Svelte listener name: cell:edit:start becomes cell-edit-start.

The web component

NameSignatureDescription
defineLatticeGrid({ tagName?, createGrid? }) => class | nullRegister <lattice-grid> (or your own tag name) and return the registered element class.
createLatticeGridElement(deps?) => classBuild the element class without registering it, for a custom registry.
GridElementControllerclassThe controller behind the element, if you are wrapping it yourself.
TAG_NAMEstringThe default tag, lattice-grid.
EVENT_PREFIXstringWhat DOM events are prefixed with.
ATTRIBUTE_CONFIGReadonly<Record<string, unknown>>Which attributes map to which configuration keys.
observedAttributeNames() => string[]The attributes the element reacts to.
domEventName(event) => stringThe DOM event name a grid event is dispatched under.
defineLatticeElements(opts?) => Record<string, class>Register one element per viewer from the factories you pass, under prefix (lattice- by default). Safe to call twice. See Web Components.
createLatticeElements(deps?) => Record<string, class>The classes, without registering them.
ELEMENT_TAGSReadonly<Record<string, string>>The tag each viewer takes, before the prefix.
createLatticeKPIElement({ createKPI }) => classThe <lattice-kpi> class on its own.
createLatticeChartElement({ createChart }) => classThe <lattice-chart> class on its own.
createLatticeKanbanElement({ createKanban }) => classThe <lattice-kanban> class on its own.
createLatticeGanttElement({ createGantt }) => classThe <lattice-gantt> class on its own.
createLatticeLayoutElement({ createLayout }) => classThe <lattice-layout> class on its own.
createLatticeTabsElement({ createTabs, createGrid? }) => classThe <lattice-tabs> class on its own.
createLatticeRouterElement({ createDataRouter }) => classThe <lattice-router> class on its own.
adoptLatticeStyles(root, opts?) => () => voidMake a shadow root see Lattice's styles, and keep it seeing them as more are generated. Returns the undo.
latticeStyleSheets(opts?) => CSSStyleSheet[]Those stylesheets as constructable sheets, for a host managing its own adoptedStyleSheets. Shared and live; do not mutate them.

The htmx module

NameSignatureDescription
initWithin(root) => Grid[]Build every grid inside a fragment htmx just swapped in.
destroyWithin(root) => voidTear them down before the fragment goes.
rebindViewersWithin(root) => numberRe-point every viewer declared with data-lattice-bind="<grid id>" at the live grid now carrying that id, and return how many it re-bound. Runs automatically on htmx:load after the new grids are built.
gridElementsWithin(root) => Element[]The grid elements in a fragment, without building them.
rowsFromFragment(fragment) => unknown[]Read rows out of server-rendered markup.
rowsFromJson(text) => unknown[]Read rows out of a JSON payload.
ingestResponse(grid, response) => voidApply an htmx response to a grid, whichever of those two shapes it carries.
saveStateWithin(root) => voidPersist the view state of every grid in a fragment before a swap.
restoreStateWithin(root) => voidPut it back afterwards.
queryParams(grid) => Record<string, string>The grid's sort, filter and page as request parameters.
warnIfLargeHtmlPayload(rows) => voidWarn once when a server-rendered payload is large enough that JSON would serve better.
QUERY_CHANGED_EVENTstringDispatched when the grid's query changes, for htmx to trigger on.
SCROLL_NEAR_END_EVENTstringDispatched as the viewport nears the end, for infinite scroll.
HTML_ROW_WARNING_THRESHOLDnumberThe row count that warning fires at.
autoInitGantt(root) => unknown[]Build a Gantt on every uninitialised [data-lattice-gantt] element under root, mounting the split view over the server-rendered <table> that element wraps. Idempotent; the factory is resolved from registerGantt() or the script-tag global, never inlined into the htmx bundle.
registerGantt(factory) => voidRegister the Gantt factory the adapter builds with - the page's own Gantt module calls this once on load.
destroyGanttWithin(root) => numberTear down every Gantt in a fragment before htmx detaches it, before the grid cleanup pass so a Gantt's inner task-table grid is released by the Gantt itself.
saveGanttStateWithin / restoreGanttStateWithin(root) => numberCarry a Gantt's zoom, visible range, scroll, collapsed rows and column widths across htmx:beforeHistorySave/htmx:historyRestore, with the original table swapped back for the snapshot.
driveGanttOobUpdates(opts?) => () => voidApply an out-of-band data-lattice-row="<task id>" fragment to that task through gantt.rows.apply - one reschedule, zoom/scroll/selection/collapse untouched.
GANTT_CHANGE_EVENTstringlattice:gantt-change - re-raised on the host for every committed change with { kind, id, task, changes } in detail; use hx-trigger="lattice:gantt-change" to POST it.
GANTT_ATTRstringThe marker attribute autoInitGantt scans for: data-lattice-gantt.
autoInitMap(root) => unknown[]Build a map on every uninitialised [data-lattice-map] element under root, bound to a grid by data-lattice-bind/config.grid. Idempotent; the factory is resolved from registerMapFactories() or the script-tag global, never inlined into the htmx bundle.
registerMapFactories({ mapview?, chart?, leaflet?, deck? }) => voidRegister the map factories the adapter builds with - the page's own modules call this once on load, one key per viewer kind.
destroyMapWithin(root) => numberTear down every map in a fragment before htmx detaches it, before the grid cleanup pass so a map's tile layers, observers and listeners are released by the viewer itself.
saveMapStateWithin / restoreMapStateWithin(root) => numberCarry a map's view state (centre, zoom, selected layer) across htmx:beforeHistorySave/htmx:historyRestore, written to data-lattice-map-state and re-applied through the wrapper's setState.
MAP_ATTRstringThe marker attribute autoInitMap scans for: data-lattice-map.
autoInitCalendar(root) => unknown[]Build a calendar on every uninitialised [data-lattice-calendar] element under root, bound to a grid by data-lattice-bind/config.grid, or hydrated straight from a server-rendered <table> inside the element - bind wins, then table, then JSON rows. Idempotent; the calendar engine is inlined into the htmx bundle, and registerCalendar() optionally overrides the factory.
registerCalendar(factory) => voidRegister a calendar factory that overrides the bundled default - optional, for an adapter that wants its own factory.
destroyCalendarWithin(root) => numberTear down every calendar in a fragment before htmx detaches it, so the calendar releases its grid-follow subscriptions before the grid cleanup pass.
saveCalendarStateWithin / restoreCalendarStateWithin(root) => numberCarry a calendar's view state (view, date, first day, time-axis scroll) across htmx:beforeHistorySave/htmx:historyRestore, with the original table swapped back for the snapshot.
restoreSwappedCalendars() => voidUndo the table swaps saveCalendarStateWithin queued, a microtask after the history snapshot.
driveCalendarOobUpdates(opts?) => () => voidApply an out-of-band data-lattice-row="<event id>" fragment to that event through calendar.rows.apply - one reschedule, the view, date and scroll untouched.
CALENDAR_ATTRstringThe marker attribute autoInitCalendar scans for: data-lattice-calendar.
autoInitKanban(root) => unknown[]Build a Kanban board on every uninitialised [data-lattice-kanban] element under root, bound to a grid by data-lattice-bind/config.grid, or hydrated straight from a server-rendered <table> inside the element - bind wins, then table, then JSON rows. Idempotent; the factory is resolved from registerKanban() or the script-tag global, never inlined into the htmx bundle.
registerKanban(factory) => voidRegister the board factory the adapter builds with - the page's own Kanban module calls this once on load.
destroyKanbanWithin(root) => numberTear down every board in a fragment before htmx detaches it, so the board releases its subscriptions to any bound grid before the grid cleanup pass.
saveKanbanStateWithin / restoreKanbanStateWithin(root) => numberCarry a board's view state (collapsed columns and lanes, column order, quick filter, sprint/epic selection, selection) across htmx:beforeHistorySave/htmx:historyRestore, with the original table swapped back for the snapshot.
restoreSwappedKanbans() => voidUndo the table swaps saveKanbanStateWithin queued, a microtask after the history snapshot.
driveKanbanOobUpdates(opts?) => () => voidApply an out-of-band data-lattice-row="<card id>" fragment to that card through board.rows.apply - one move, collapsed columns and the sprint filter untouched.
KANBAN_ATTRstringThe marker attribute autoInitKanban scans for: data-lattice-kanban.

The devtools module

NameSignatureDescription
expose(grid, name?) => voidPut a grid on globalThis under a name, so a console session can reach it.

Windowed aggregates

The primitives behind grid.statistics.windowed, for a host that drives a live stream itself: a sliding window over timestamped values that re-reduces on demand and stamps every figure with the window it covered. Reduce over the last N ticks, the last N minutes, or the whole session.

NameSignatureDescription
openWindow(opts, now?) => WindowBuild a window from a spec: { kind: 'count', span } for the last N ticks, { kind: 'time', minutes } for the last N minutes, or { kind: 'session' } for everything since it opened.
WindowclassA sliding window. push(v, t?) adds a value, reduce(fn) returns one named aggregate stamped with the window in over, and aggregate() returns them all at once.
WINDOW_KINDSreadonly ('count' | 'time' | 'session')[]The three window kinds a caller may ask for.
const { openWindow, Window, WINDOW_KINDS } = await import('../packages/core/src/index.js');
// openWindow builds one of the three kinds; here, the last 3 ticks.
const live = openWindow({ kind: 'count', span: 3 });
for (const v of [10, 20, 30, 40]) live.push(v); // 10 is evicted; 20, 30, 40 remain
const avg = live.reduce('avg'); // (20 + 30 + 40) / 3 = 30, stamped with the window it covers
// A Window can be built directly too; a session window never evicts.
const session = new Window('session');
session.push(5);
session.push(15);
return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')}; session ${session.reduce('avg').value}`;

Anomaly detection

Flag the rows that do not belong. Interpretable statistics with a written-down cut, never a black box: a robust per-column outlier score, Tukey's fences, and multivariate distance from the joint centre. These are the pure kernels behind grid.statistics.anomalies(...) and the anomalyScore/anomalyFlag shadow columns; a host can score a plain array the same way the grid scores a column.

NameSignatureDescription
ANOMALY_METHODSreadonly ('modifiedZScore' | 'iqr' | 'mahalanobis')[]The three methods a caller may ask for, named so a result can say which produced a flag.
modifiedZScores(values, opts?) => { median, mad, threshold, scores, flags, flagged }Per-row robust outlier score, 0.6745·(x − median)/MAD, flagged past threshold (default 3.5). Built on the median and MAD, so one wild reading cannot inflate the spread and hide - the masking effect that fools an ordinary z-score. A non-finite reading and a zero-MAD column yield a null score, not an invented one.
iqrFences(values, opts?) => { q1, q3, iqr, lower, upper, k } | nullTukey's fences, [Q1 − k·IQR, Q3 + k·IQR] (default k = 1.5), the same fence the box plot draws, on R type 7 quartiles.
mahalanobis(matrix, opts?) => { center, df, cutoff, singular, used, distances, squared, flags, flagged } | nullDistance of every row from the joint centre in the metric of the data's own covariance, cut at a χ² quantile (default the 0.975 point). Catches a row impossible only in combination - heavy and short - that a per-column scan misses. A row with any missing coordinate is left unplaced; a singular covariance is ridge-regularised and reported as singular rather than throwing.
ROLLING_ANOMALY_METHODSreadonly ('rollingModifiedZScore' | 'rollingIqr')[]The rolling (windowed) methods, named alongside ANOMALY_METHODS so a caller can enumerate every detector. Also accepted by grid.statistics.anomalies({ method, windowLen }).
rollingAnomalies(values, opts?) => { method, windowLen, minPeriods, threshold, k, scores, flags, flagged }Rolling (windowed) detection: judge every reading against a causal trailing window of windowLen ending at it, so a spike is caught against its recent neighbours and a drift never poisons a global baseline. rollingModifiedZScore (median + MAD) or rollingIqr (Tukey fences). With a window as long as the series the last point's score equals the static modifiedZScores one.
anomalyCondition(opts) => (rows) => false | { method, field, flagged }Build a Data Router alert condition from a detector: a (rows) => signal the router's existing router.alert(value, condition, handler) drives, so live anomaly monitoring reuses the router's partitioning, debounce and rising-edge re-arm rather than duplicating an alert engine. Reads one numeric field per row; latest: true signals only when the newest reading is the anomaly.
const { modifiedZScores, iqrFences, mahalanobis, ANOMALY_METHODS } = await import('../packages/core/src/index.js');
// Univariate: the robust modified z-score flags the 500 among steady readings,
// where the mean and standard deviation an ordinary z uses would be dragged up
// by the outlier until it no longer looked like one.
const z = modifiedZScores([20, 21, 19, 20, 21, 19, 20, 500]);
// Tukey's fences say the same, drawn the way a box plot draws them.
const fence = iqrFences([20, 21, 19, 20, 21, 19, 20, 500]);
// Multivariate: height and weight move together; the last person is tall but
// very light, so the pair is impossible even though neither number is extreme.
const hw = [];
for (let i = 0; i < 12; i++) hw.push([160 + i, 60 + i]);
hw.push([182, 45]);
const m = mahalanobis(hw); // the χ² cut flags the off-line row
return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.upper}; joint ${m.flags[12]}`;

Rolling (windowed) detection and the Data Router bridge: judge each reading against its trailing window for live monitoring, and turn any detector into a router.alert(...) condition without a second alert engine.

const { rollingAnomalies, anomalyCondition, ROLLING_ANOMALY_METHODS } = await import('../packages/core/src/index.js');
// A steady stream that suddenly spikes. The trailing window catches the spike
// against its recent neighbours, where scoring against the whole run could let a
// long earlier drift hide it.
const stream = [10, 11, 9, 10, 11, 9, 10, 11, 9, 10, 40];
const roll = rollingAnomalies(stream, { windowLen: 6, minPeriods: 4 });
// The SAME detector, wired as a Data Router alert condition: (rows) => signal,
// exactly the predicate router.alert(value, condition, handler) already drives - // no second alert engine. `latest` fires only when the newest tick is the one out.
const condition = anomalyCondition({ field: 'temp', method: 'rollingModifiedZScore', orderBy: 't', windowLen: 6, minPeriods: 4, latest: true });
const rows = stream.map((temp, t) => ({ t, temp }));
const signal = condition(rows);
return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; alert ${signal ? signal.flagged.length : 0}`;

Forecasting

Project an ordered series forward, and carry a prediction band where a defensible closed form exists. Five methods behind one entry point: movingAverage and ses are flat forecasts (the trailing-window mean, the final smoothed level); holt adds a projected trend, holtWinters a projected trend and an additive seasonal; linear extrapolates an ordinary least-squares fit of the time axis. The exponential-smoothing bands are the innovations state-space forecast variances (Hyndman & Athanasopoulos) at the normal quantile; the linear and moving-average bands are the exact Student-t intervals, and linear also reports the narrower mean-response (confidence) band a trendline draws. These are the pure kernels the chart trendline overlay and the time-series grid forecast from; a host can forecast a plain array the same way.

NameSignatureDescription
FORECAST_METHODSreadonly ('movingAverage' | 'ses' | 'holt' | 'holtWinters' | 'linear')[]The five methods a caller may ask for, named so a result can say which produced it.
forecast(seq, opts?) => ForecastResult | nullForecast an ordered series opts.horizon steps ahead by opts.method (default linear), at opts.confidence (default 0.95). Accepts a plain array of numbers (index is the time axis) or {at, value} rows. A smoothing factor absent from opts (alpha/beta/gamma) is fit by minimising the in-sample one-step SSE; holtWinters needs opts.period (≥ 2) and two whole periods of data. Each point carries mean and, where a band applies, lower/upper; linear adds lowerMean/upperMean. Null when the series is too short for the method.
const { forecast, FORECAST_METHODS } = await import('../packages/core/src/index.js');
// Linear: fit the time axis, project one step, and carry the prediction band.
const lin = forecast([{ at: 1, value: 2 }, { at: 2, value: 4 }, { at: 3, value: 5 }, { at: 4, value: 4 }, { at: 5, value: 5 }], { method: 'linear', horizon: 1 });
const p = lin.points[0];
// Holt-Winters additive: a level, a trend and a two-step season, projected two
// steps ahead - the seasonal swing is carried into the forecast, not smoothed away.
const hw = forecast([10, 20, 30, 40], { method: 'holtWinters', period: 2, alpha: 0.5, beta: 0.5, gamma: 0.5, horizon: 2 });
return `methods ${FORECAST_METHODS.length}; next ${p.mean.toFixed(1)}; r2 ${lin.r2.toFixed(1)}; band ${p.lower.toFixed(2)}..${p.upper.toFixed(2)}; season ${hw.points[1].mean}`;

The same forecast off the standard stats surface: grid.statistics.forecast(colId, opts) reads the column over the filtered rows - ordered by opts.by when the time axis matters, exactly as grid.statistics.series(...) orders - and returns the same ForecastResult, so a host reaches a forecast the way it reaches grid.statistics.anomalies(...) rather than assembling the series itself.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
  columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }],
  rows: [[1, 2], [2, 4], [3, 5], [4, 4], [5, 5]].map(([t, v], i) => ({ id: String(i), t, v })),
  rowKey: 'id',
});
// Forecast the 'v' column one step ahead, ordered by 't', with the prediction band.
const f = grid.statistics.forecast('v', { by: 't', method: 'linear', horizon: 1 });
const p = f.points[0];
return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.lower > 0}`;