demo D289
Ask your data a question
Ask in plain English and the grid answers by filtering, sorting and grouping itself: the model returns a query, the grid validates it against its own columns and shows it before it applies, and one question moves the grid, a chart and the tiles together
createAI · ai.query · ai.applyQuery · ai.askBar
The configuration
'ask-your-data': () => ({
rows: [],
config: {},
foot: [
'the model callback runs in this tab: no key, no network request',
'the model returns a query, never rows; the grid validates it against its own columns and runs it in its own engine',
'the resolved query is shown in plain English, and nothing changes until you press Apply',
'it only reshapes the view: it filters, sorts and groups, and never changes your data',
'one question updates the grid, the chart and the tiles together, through createDataRouter',
'in a product you pass your own model through ask(); the grid still makes no AI call of its own',
],
mount: (el: HTMLElement, LG: any) => {
let a = 0x1a2b3c4d >>> 0;
const rnd = () => { a = (a + 0x6d2b79f5) >>> 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const pick = <T,>(list: T[]): T => list[Math.floor(rnd() * list.length)];
const REGIONS = ['EMEA', 'AMER', 'APAC'];
const STATUSES = ['Paid', 'Pending', 'Overdue'];
const CUSTOMERS = ['Northwind', 'Contoso', 'Fabrikam', 'Adventure Works', 'Tailspin', 'Wingtip'];
const invoices: Invoice[] = [];
for (let i = 0; i < 64; i++) {
const month = 1 + Math.floor(rnd() * 6);
const day = 1 + Math.floor(rnd() * 27);
invoices.push({
id: 'INV-' + String(3100 + i),
kind: 'invoice',
region: pick(REGIONS),
customer: pick(CUSTOMERS),
status: pick(STATUSES),
amount: 500 + Math.round(rnd() * 39500),
due: `2026-0${month}-${String(day).padStart(2, '0')}`,
});
}
const money = { style: 'currency', currency: 'USD', decimals: 0 } as const;
const askCol = document.createElement('div');
askCol.style.minWidth = '0';
const chipRow = document.createElement('div');
chipRow.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px';
const chipLabel = document.createElement('span');
chipLabel.style.cssText = 'font-size:.875rem;color:var(--ink-2);align-self:center;margin-right:2px';
chipLabel.textContent = 'Try a question:';
chipRow.append(chipLabel);
const barEl = document.createElement('div');
barEl.className = 'lattice';
barEl.style.cssText = 'min-width:0;'
+ '--lat-ai-bg:var(--paper);--lat-ai-border:var(--rule);--lat-ai-btn:var(--surface,var(--paper));'
+ '--lat-ai-muted:var(--ink-3);--lat-ai-focus:var(--accent,#2563eb)';
askCol.append(gridTitle('Ask your data'), chipRow, barEl, note('The model returns a query, not data. The grid checks it against its own columns, shows it in plain English, and runs it in its own engine only when you press Apply.'));
const gridEl = document.createElement('div');
gridEl.style.cssText = 'height:320px;border:1px solid var(--rule);border-radius:9px;background:var(--paper);min-width:0';
const gridCol = document.createElement('div');
gridCol.style.cssText = 'min-width:0;margin-top:12px';
gridCol.append(gridTitle('Invoices'), gridEl);
const chartEl = document.createElement('div');
chartEl.style.cssText = 'height:260px;border:1px solid var(--rule);border-radius:9px;background:var(--paper);min-width:0;padding:8px';
const chartCol = document.createElement('div');
chartCol.style.cssText = 'min-width:0;flex:1 1 320px';
chartCol.append(gridTitle('Amount by region'), chartEl);
const kpiEl = document.createElement('div');
kpiEl.style.cssText = 'border:1px solid var(--rule);border-radius:9px;background:var(--paper);padding:12px;min-width:0';
const kpiCol = document.createElement('div');
kpiCol.style.cssText = 'min-width:0;flex:1 1 320px';
kpiCol.append(gridTitle('Live figures'), kpiEl);
const panelRow = document.createElement('div');
panelRow.style.cssText = 'display:flex;flex-wrap:wrap;gap:12px;margin-top:12px';
panelRow.append(chartCol, kpiCol);
el.append(askCol, gridCol, panelRow);
const grid = LG.createGrid(gridEl, {
rowKey: 'id',
theme: 'light',
columns: [
{ field: 'id', title: 'Ref', layout: { width: 104, pin: 'start' } },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'customer', title: 'Customer', filter: { type: 'text' } },
{ field: 'status', title: 'Status', filter: { type: 'set' } },
{ field: 'amount', title: 'Amount', type: 'number', format: money, total: 'sum' },
{ field: 'due', title: 'Due', type: 'dateString' },
],
rows: invoices,
});
let watching: MutationObserver | undefined;
const themed = [barEl, kpiEl];
if (grid.element) {
const copy = () => {
const t = grid.element.getAttribute('data-theme') || 'light';
for (const node of themed) node.setAttribute('data-theme', t);
};
copy();
watching = new MutationObserver(copy);
watching.observe(grid.element, { attributes: true, attributeFilter: ['data-theme'] });
}
let ai: any;
let chart: any;
let kpi: any;
let router: any;
let disposed = false;
Promise.all([loadAI(), loadCharts(), loadKPI(), loadDataRouter()])
.then(([AI, CH, K, DR]: any[]) => {
if (disposed) return;
chart = CH.createChart({ grid, container: chartEl, type: 'bar', x: 'region', y: 'amount', title: 'Amount by region' });
kpi = K.createKPI(kpiEl, {
rowKey: 'id',
columns: 4,
tiles: [
{ id: 'total', label: 'Total shown', aggregation: 'sum', field: 'amount', format: 'currency' },
{ id: 'count', label: 'Invoices shown', aggregation: 'count' },
{ id: 'avg', label: 'Average', aggregation: 'avg', field: 'amount', format: { type: 'currency', decimals: 0 } },
{ id: 'biggest', label: 'Biggest', aggregation: 'max', field: 'amount', format: 'currency' },
],
});
router = DR.createDataRouter({ key: 'kind', rowKey: 'id', overlap: true });
router.attach(kpi, 'invoice');
router.load(invoices);
ai = AI.createAI(grid, { ask: mockAsk, tools: false, router, autoApply: false, locale: 'en-US' });
ai.askBar(barEl, {
router,
placeholder: 'Ask your data: “show overdue invoices by region”',
autoLabel: 'Apply questions that only filter, sort or group on their own',
});
const input = barEl.querySelector('.lat-ai-ask__input') as HTMLInputElement | null;
const askBtn = barEl.querySelector('.lat-ai-ask__ask') as HTMLButtonElement | null;
for (const c of CANNED) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'lattice';
chip.textContent = c.q;
chip.style.cssText = 'font-size:.8125rem;padding:5px 11px;border:1px solid var(--rule);border-radius:999px;'
+ 'background:var(--paper);color:var(--ink);cursor:pointer';
chip.addEventListener('click', () => {
if (!input || !askBtn) return;
input.value = c.q;
askBtn.click();
});
chipRow.append(chip);
}
})
.catch((err) => console.error('[ask-your-data]', err));
return () => {
disposed = true;
watching?.disconnect();
ai?.destroy?.();
chart?.destroy?.();
kpi?.destroy?.();
router?.destroy?.();
grid?.destroy?.();
};
},
})
Ask your data a question, and watch the grid answer
Type a question the way you would say it out loud, “show overdue invoices by region” or “sort by amount, biggest first”, and the grid answers by filtering, sorting and grouping itself. There is nothing to learn: no operators, no query language, no menu to hunt through. The people who read your grid all day can now shape it in a sentence, and the shaping is the same shaping they would have done by hand, so it lands on the same undo step and reads the same in every view.
One question can move several views at once. Ask for overdue invoices by region and the grid narrows, the chart redraws for the narrower set, and the stat tiles retotal, all from the one question, because they are all reading the same rows.
How you can trust the answer
The model never touches your data. It returns a query, a short description of which columns and operators to use, and never a single row. The grid takes that query, checks it against its own columns and their types, and runs it in its own engine, so every row you see is a real row the grid returned, not something a model wrote.
Before anything changes, the resolved query is shown back to you in plain English, “Filter Status is Overdue, sort Amount, largest first”, and you press Apply. Nothing happens until you do. If you would rather not confirm every time, you can let questions that only filter, sort or group the view apply on their own, and that is off until you turn it on. A question that would do anything more than reshape what you are looking at is refused.
It only reshapes the view. It filters, sorts and groups the rows in front of you. It does not edit, add or remove your data. A question that cannot be turned into one of those changes is declined with the reason, rather than guessed at.
Your model, your key, and only your schema leaves
Asking your data in plain language is part of the grid you already have, not a separate product and not an added cost. The grid ships no model and holds no key, and it makes no AI call of its own. You supply one small function, ask(), that wraps the model and key you already use.
What leaves the browser is only what your ask() sends, and that is the schema, the names and types of your columns, so the model knows what it is allowed to ask for. Your row values never leave to get an answer, because the model only ever proposes a query and the grid runs it here against the real rows.
How do I let people filter a data grid in plain English?
Load the AI module and call createAI(grid, { ask }), passing the live grid and your own model callback. Then call ai.askBar(element) to drop an ask bar above the grid: a person types a question, sees the resolved query in plain English, and presses Apply. For your own interface, ai.query(question) returns the validated query and ai.applyQuery(result) runs it, so you can render the preview and the Apply button however you like.
How does asking my data stay safe?
The model returns a query, never rows, and the grid validates that query against its own columns and operators before it runs. A query naming a column that does not exist, or an operator that does not fit a column’s type, is refused rather than half applied. The resolved query is shown in plain English and waits for you to press Apply, and the whole feature only reshapes your view, so it can filter, sort and group but never change your data.
Wire your own model in production
The demo on this page uses a stand-in ask() that runs in your browser with no key, so the page works offline: it recognises a few questions and turns each into a real query the grid then validates and runs. In a product you replace that one function with a call to the model you choose. Everything else stays the same, and the guarantees are the same whichever model you wire in, because the grid validates the query and runs it here against its own rows.